mirror of
https://github.com/motajs/template.git
synced 2026-11-05 00:34:34 +08:00
refactor: 构建脚本
This commit is contained in:
1 parent
d35f8303da
commit
3cc97e5ed3
8 files changed
+672
-833
No files matched your search
+141
-570
@@ -1,21 +1,10 @@
|
||||
import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite';
|
||||
import legacy from '@vitejs/plugin-legacy';
|
||||
import { resolve } from 'path';
|
||||
import { copy, emptyDir, ensureDir, pathExists } from 'fs-extra';
|
||||
import { OutputAsset, OutputChunk, RollupOutput } from 'rollup';
|
||||
import Fontmin from 'fontmin';
|
||||
import { readdir, readFile, rmdir, stat, writeFile } from 'fs/promises';
|
||||
import { transformAsync } from '@babel/core';
|
||||
import archiver from 'archiver';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { zip } from 'compressing';
|
||||
import { RequiredData, RequiredIconsData } from './types';
|
||||
import {
|
||||
CompressedUsage,
|
||||
LoadDataType,
|
||||
splitResource,
|
||||
SplittedResource
|
||||
} from './build-resource';
|
||||
import { copy, emptyDir, ensureDir } from 'fs-extra';
|
||||
import { readdir, readFile, stat } from 'fs/promises';
|
||||
import { dataIsolationPlugin } from './build-plugin';
|
||||
import { buildContent } from './build-resource';
|
||||
import { formatSize } from './utils';
|
||||
|
||||
/** 打包调试 */
|
||||
@@ -27,15 +16,56 @@ const ansi = {
|
||||
clear: '\x1b[2J\x1b[0f'
|
||||
};
|
||||
|
||||
// 资源分离步骤的单包大小,默认 2M,可以自行调整
|
||||
const RESOUCE_SIZE = 2 * 2 ** 20;
|
||||
|
||||
const distDir = resolve(process.cwd(), 'dist');
|
||||
const tempDir = resolve(process.cwd(), '_temp');
|
||||
|
||||
const enum ProgressStatus {
|
||||
Success,
|
||||
Fail,
|
||||
Working,
|
||||
Warn
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建游戏代码
|
||||
* @param entry 入口文件
|
||||
* 输出构建步骤
|
||||
* @param step 步骤数
|
||||
* @param final 最后一步的状态
|
||||
*/
|
||||
function logProgress(step: number, final: ProgressStatus) {
|
||||
const list = [
|
||||
`1. 构建前准备`,
|
||||
`2. 构建渲染端`,
|
||||
`3. 构建数据端`,
|
||||
`4. 处理 content`,
|
||||
`5. 收尾`
|
||||
];
|
||||
|
||||
const str = list.reduce((prev, curr, idx) => {
|
||||
if (idx > step) {
|
||||
return prev;
|
||||
} else if (idx === step) {
|
||||
switch (final) {
|
||||
case ProgressStatus.Fail:
|
||||
return prev + `❌ ${curr}\r\n错误信息:\r\n`;
|
||||
case ProgressStatus.Success:
|
||||
return prev + `✅ ${curr}\r\n`;
|
||||
case ProgressStatus.Working:
|
||||
return prev + `🔄 ${curr}\r\n`;
|
||||
case ProgressStatus.Warn:
|
||||
return prev + `⚠️ ${curr}\r\n`;
|
||||
}
|
||||
} else {
|
||||
return prev + `✅ ${curr}\r\n`;
|
||||
}
|
||||
}, '');
|
||||
|
||||
process.stdout.write(ansi.clear);
|
||||
process.stdout.write(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建渲染端代码,以 index.html 为默认入口输出到 dist
|
||||
* @param outDir 输出目录
|
||||
*/
|
||||
async function buildClient(outDir: string) {
|
||||
const configFile = resolve(process.cwd(), 'vite.config.ts');
|
||||
@@ -59,7 +89,8 @@ async function buildClient(outDir: string) {
|
||||
],
|
||||
build: {
|
||||
outDir,
|
||||
copyPublicDir: true,
|
||||
copyPublicDir: false,
|
||||
emptyOutDir: false,
|
||||
minify: !DEBUG_BUILD,
|
||||
rollupOptions: {
|
||||
external: ['@wasm-audio-decoders/opus-ml'],
|
||||
@@ -88,6 +119,11 @@ async function buildClient(outDir: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 以库模式构建数据端代码,入口产物固定为 dist/data.js
|
||||
* @param outDir 输出目录
|
||||
* @param entry 数据端入口文件
|
||||
*/
|
||||
async function buildData(outDir: string, entry: string) {
|
||||
const configFile = resolve(process.cwd(), 'vite.config.ts');
|
||||
const config = await loadConfigFromFile(
|
||||
@@ -95,16 +131,24 @@ async function buildData(outDir: string, entry: string) {
|
||||
configFile
|
||||
);
|
||||
const resolved = mergeConfig(config?.config ?? {}, {
|
||||
plugins: [dataIsolationPlugin()],
|
||||
build: {
|
||||
outDir,
|
||||
copyPublicDir: false,
|
||||
emptyOutDir: false,
|
||||
minify: !DEBUG_REPLAY,
|
||||
lib: {
|
||||
entry,
|
||||
name: 'ProcessData',
|
||||
fileName: 'data',
|
||||
formats: ['iife']
|
||||
formats: ['es']
|
||||
},
|
||||
minify: !DEBUG_REPLAY
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'data.js',
|
||||
chunkFileNames: 'data/[name].[hash].js',
|
||||
assetFileNames: 'data/[name].[hash][extname]'
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies UserConfig);
|
||||
|
||||
@@ -114,590 +158,117 @@ async function buildData(outDir: string, entry: string) {
|
||||
});
|
||||
}
|
||||
|
||||
const enum ProgressStatus {
|
||||
Success,
|
||||
Fail,
|
||||
Working,
|
||||
Warn
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出构建步骤
|
||||
* @param step 步骤数
|
||||
* @param final 最后一步的状态
|
||||
* 递归收集目录下的文件
|
||||
* @param dir 待遍历目录
|
||||
* @param output 收集结果
|
||||
* @param extension 需要保留的扩展名
|
||||
* @param ignored 需要跳过的目录
|
||||
*/
|
||||
function logProgress(step: number, final: ProgressStatus) {
|
||||
const list = [
|
||||
`1. 构建前准备`,
|
||||
`2. 构建客户端代码`,
|
||||
`3. 构建数据端代码`,
|
||||
`4. 压缩 main.js`,
|
||||
`5. 压缩字体`,
|
||||
`6. 资源分块`,
|
||||
`7. 最后处理`,
|
||||
`8. 压缩为 zip`
|
||||
];
|
||||
|
||||
const str = list.reduce((prev, curr, idx) => {
|
||||
if (idx > step) {
|
||||
return prev;
|
||||
} else if (idx === step) {
|
||||
switch (final) {
|
||||
case ProgressStatus.Fail:
|
||||
return prev + `❌ ${curr}\r\n错误信息:\r\n`;
|
||||
case ProgressStatus.Success:
|
||||
return prev + `✅ ${curr}\r\n`;
|
||||
case ProgressStatus.Working:
|
||||
return prev + `🔄 ${curr}\r\n`;
|
||||
case ProgressStatus.Warn:
|
||||
return prev + `⚠️ ${curr}\r\n`;
|
||||
}
|
||||
} else {
|
||||
return prev + `✅ ${curr}\r\n`;
|
||||
}
|
||||
}, '');
|
||||
|
||||
process.stdout.write(ansi.clear);
|
||||
process.stdout.write(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取规范化文件名
|
||||
* @param output Rollup 输出
|
||||
* @param client 是否是客户端代码
|
||||
*/
|
||||
function getFileName(output: OutputChunk | OutputAsset, client: boolean) {
|
||||
const name = output.fileName;
|
||||
if (name.startsWith('index-legacy') && client) {
|
||||
return 'main';
|
||||
}
|
||||
if (name.startsWith('data') && !client) {
|
||||
return 'main';
|
||||
}
|
||||
if (name.startsWith('index.html') && client) {
|
||||
return 'index';
|
||||
}
|
||||
const index = name.indexOf('-legacy');
|
||||
const unhash = name.slice(0, index);
|
||||
return unhash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件大小
|
||||
* @param output Rollup 输出
|
||||
*/
|
||||
function getFileSize(output: OutputChunk | OutputAsset) {
|
||||
if (output.type === 'asset') {
|
||||
if (typeof output.source === 'string') {
|
||||
return Buffer.byteLength(output.source);
|
||||
} else {
|
||||
return output.source.byteLength;
|
||||
}
|
||||
} else {
|
||||
return Buffer.byteLength(output.code);
|
||||
}
|
||||
}
|
||||
|
||||
const enum ClientDataLevel {
|
||||
Error,
|
||||
Suspect,
|
||||
Pass
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查客户端与数据端文件大小,并给出数据端引用客户端的可能性
|
||||
* @param client 客户端文件大小
|
||||
* @param data 数据端文件大小
|
||||
*/
|
||||
function checkClientData(
|
||||
client: Map<string, number>,
|
||||
data: Map<string, number>
|
||||
async function collectFiles(
|
||||
dir: string,
|
||||
output: string[],
|
||||
extension: string,
|
||||
ignored: string[] = []
|
||||
) {
|
||||
let error = false;
|
||||
let warn = false;
|
||||
|
||||
const clientMain = client.get('main');
|
||||
const dataMain = data.get('main');
|
||||
|
||||
if (clientMain && dataMain) {
|
||||
if (clientMain <= dataMain) {
|
||||
error = true;
|
||||
} else if (clientMain / 2 < dataMain) {
|
||||
warn = true;
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (ignored.includes(full)) continue;
|
||||
await collectFiles(full, output, extension, ignored);
|
||||
} else if (entry.name.endsWith(extension)) {
|
||||
output.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
let clientTotal = 0;
|
||||
let dataTotal = 0;
|
||||
|
||||
client.forEach(v => {
|
||||
clientTotal += v;
|
||||
});
|
||||
|
||||
data.forEach(v => {
|
||||
dataTotal += v;
|
||||
});
|
||||
|
||||
if (clientTotal <= dataTotal) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
if (clientTotal / 4 < dataTotal) {
|
||||
warn = true;
|
||||
}
|
||||
|
||||
if (error) return ClientDataLevel.Error;
|
||||
else if (warn) return ClientDataLevel.Suspect;
|
||||
else return ClientDataLevel.Pass;
|
||||
}
|
||||
|
||||
async function getAllChars(client: RollupOutput[]) {
|
||||
/**
|
||||
* 收集渲染端产物与 content 中出现的全部字符,用于字体子集化
|
||||
*/
|
||||
async function getAllChars(): Promise<string> {
|
||||
const chars = new Set<string>();
|
||||
|
||||
// 1. 客户端构建结果
|
||||
client.forEach(v => {
|
||||
v.output.forEach(v => {
|
||||
if (v.type === 'chunk' && v.fileName.startsWith('index')) {
|
||||
const set = new Set(v.code);
|
||||
set.forEach(v => chars.add(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2. 样板内容
|
||||
const files: string[] = [];
|
||||
|
||||
files.push(resolve(tempDir, 'client/main.js'));
|
||||
files.push(resolve(tempDir, 'client/project/data.js'));
|
||||
files.push(resolve(tempDir, 'client/project/enemys.js'));
|
||||
files.push(resolve(tempDir, 'client/project/events.js'));
|
||||
files.push(resolve(tempDir, 'client/project/functions.js'));
|
||||
files.push(resolve(tempDir, 'client/project/icons.js'));
|
||||
files.push(resolve(tempDir, 'client/project/items.js'));
|
||||
files.push(resolve(tempDir, 'client/project/maps.js'));
|
||||
await collectFiles(distDir, files, '.js', [
|
||||
resolve(distDir, 'data'),
|
||||
resolve(distDir, 'content')
|
||||
]);
|
||||
await collectFiles(resolve(process.cwd(), 'src/content'), files, '.jsonc');
|
||||
|
||||
const floors = await readdir(resolve(tempDir, 'client/project/floors'));
|
||||
const ids = floors.map(v => resolve(tempDir, 'client/project/floors', v));
|
||||
files.push(...ids);
|
||||
for (const file of files) {
|
||||
const text = await readFile(file, 'utf-8');
|
||||
for (const char of text) chars.add(char);
|
||||
}
|
||||
|
||||
const libs = await readdir(resolve(tempDir, 'client/libs'));
|
||||
files.push(...libs.map(v => resolve(tempDir, 'client/libs', v)));
|
||||
|
||||
await Promise.all(
|
||||
files.map(async v => {
|
||||
const stats = await stat(v);
|
||||
if (!stats.isFile()) return;
|
||||
const file = await readFile(v, 'utf-8');
|
||||
const set = new Set(file);
|
||||
set.forEach(v => chars.add(v));
|
||||
})
|
||||
);
|
||||
|
||||
return chars;
|
||||
return [...chars].join('');
|
||||
}
|
||||
|
||||
interface CompressedLoadListItem {
|
||||
readonly readAs: LoadDataType;
|
||||
readonly name: string;
|
||||
readonly usage: CompressedUsage;
|
||||
}
|
||||
|
||||
type CompressedLoadList = Record<string, CompressedLoadListItem[]>;
|
||||
|
||||
/**
|
||||
* 生成资源地图 json 文件
|
||||
* 递归统计目录体积
|
||||
* @param dir 待统计目录
|
||||
*/
|
||||
function generateResourceJSON(resources: SplittedResource[]) {
|
||||
const list: CompressedLoadList = {};
|
||||
|
||||
resources.forEach(file => {
|
||||
const uri = `project/resource/${file.fileName}`;
|
||||
file.content.forEach(content => {
|
||||
const item: CompressedLoadListItem = {
|
||||
readAs: content.readAs,
|
||||
name: content.name,
|
||||
usage: content.usage
|
||||
};
|
||||
list[uri] ??= [];
|
||||
list[uri].push(item);
|
||||
});
|
||||
});
|
||||
|
||||
return JSON.stringify(list);
|
||||
async function getDirectorySize(dir: string): Promise<number> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
let total = 0;
|
||||
for (const entry of entries) {
|
||||
const full = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += await getDirectorySize(full);
|
||||
} else {
|
||||
total += (await stat(full)).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function buildGame() {
|
||||
logProgress(0, ProgressStatus.Working);
|
||||
|
||||
//#region 准备步骤
|
||||
try {
|
||||
await ensureDir(distDir);
|
||||
await ensureDir(tempDir);
|
||||
await emptyDir(distDir);
|
||||
await emptyDir(tempDir);
|
||||
await ensureDir(resolve(tempDir, 'fonts'));
|
||||
await ensureDir(resolve(tempDir, 'common'));
|
||||
await ensureDir(resolve(tempDir, 'resource'));
|
||||
} catch (e) {
|
||||
logProgress(0, ProgressStatus.Fail);
|
||||
process.stderr.write(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
await ensureDir(distDir);
|
||||
await ensureDir(tempDir);
|
||||
await emptyDir(distDir);
|
||||
await emptyDir(tempDir);
|
||||
|
||||
logProgress(1, ProgressStatus.Working);
|
||||
|
||||
//#region 构建客户端
|
||||
const clientPack = await buildClient(resolve(tempDir, 'client')).catch(
|
||||
reason => {
|
||||
logProgress(1, ProgressStatus.Fail);
|
||||
process.stderr.write(String(reason));
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
||||
await buildClient(distDir);
|
||||
|
||||
logProgress(2, ProgressStatus.Working);
|
||||
|
||||
//#region 构建数据端
|
||||
const dataPack = await buildData(
|
||||
resolve(tempDir, 'data'),
|
||||
resolve(process.cwd(), 'src/data.ts')
|
||||
).catch(reason => {
|
||||
logProgress(2, ProgressStatus.Fail);
|
||||
process.stderr.write(String(reason));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const clientSize = new Map<string, number>();
|
||||
const dataSize = new Map<string, number>();
|
||||
|
||||
const clientPackArr = [];
|
||||
const dataPackArr = [];
|
||||
|
||||
// 判断客户端与数据端的构建包大小,从而推断是否出现了数据端引用客户端的问题
|
||||
|
||||
if (clientPack instanceof Array) {
|
||||
clientPackArr.push(...clientPack);
|
||||
} else if ('close' in clientPack) {
|
||||
// pass.
|
||||
} else {
|
||||
clientPackArr.push(clientPack);
|
||||
}
|
||||
if (dataPack instanceof Array) {
|
||||
dataPackArr.push(...dataPack);
|
||||
} else if ('close' in dataPack) {
|
||||
// pass.
|
||||
} else {
|
||||
dataPackArr.push(dataPack);
|
||||
}
|
||||
|
||||
// 获取每个 chunk 的大小
|
||||
clientPackArr.forEach(v => {
|
||||
v.output.forEach(v => {
|
||||
const name = getFileName(v, true);
|
||||
const size = getFileSize(v);
|
||||
clientSize.set(name, size);
|
||||
});
|
||||
});
|
||||
dataPackArr.forEach(v => {
|
||||
v.output.forEach(v => {
|
||||
const name = getFileName(v, false);
|
||||
const size = getFileSize(v);
|
||||
dataSize.set(name, size);
|
||||
});
|
||||
});
|
||||
|
||||
const level = checkClientData(clientSize, dataSize);
|
||||
|
||||
if (level === ClientDataLevel.Error) {
|
||||
logProgress(2, ProgressStatus.Fail);
|
||||
process.stderr.write(
|
||||
`数据端似乎引用了客户端内容,请仔细检查后再构建!`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 解析全塔属性
|
||||
const dataFile = await readFile(
|
||||
resolve(process.cwd(), 'public/project/data.js'),
|
||||
'utf-8'
|
||||
);
|
||||
const dataObject: RequiredData = JSON.parse(
|
||||
dataFile.split('\n').slice(1).join('\n')
|
||||
);
|
||||
const mainData = dataObject.main;
|
||||
await buildData(distDir, resolve(process.cwd(), 'src/data.ts'));
|
||||
|
||||
logProgress(3, ProgressStatus.Working);
|
||||
|
||||
//#region 压缩 main
|
||||
try {
|
||||
const main = await readFile(
|
||||
resolve(tempDir, 'client/main.js'),
|
||||
'utf-8'
|
||||
);
|
||||
const [head, tail] = main.split('// >>>> body end');
|
||||
const transformed = await transformAsync(tail, {
|
||||
presets: [['@babel/preset-env']],
|
||||
sourceType: 'script',
|
||||
minified: true,
|
||||
comments: false
|
||||
});
|
||||
if (!transformed || !transformed.code) {
|
||||
throw new ReferenceError(
|
||||
`Cannot write main.js since transform result is empty.`
|
||||
);
|
||||
}
|
||||
const code = transformed.code;
|
||||
const modifiedHead = head.replace(
|
||||
'this.scriptCompress = false;',
|
||||
'this.scriptCompress = true;'
|
||||
);
|
||||
await writeFile(
|
||||
resolve(tempDir, 'common/main.js'),
|
||||
modifiedHead + '\n// >>>> body end\n' + code,
|
||||
'utf-8'
|
||||
);
|
||||
} catch (e) {
|
||||
logProgress(3, ProgressStatus.Fail);
|
||||
process.stderr.write(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
//#region 压缩字体
|
||||
const chars = await getAllChars(clientPackArr).catch(reason => {
|
||||
logProgress(4, ProgressStatus.Fail);
|
||||
process.stderr.write(String(reason));
|
||||
process.exit(1);
|
||||
});
|
||||
const { fonts } = mainData;
|
||||
|
||||
await Promise.all(
|
||||
fonts.map(v => {
|
||||
const fontmin = new Fontmin();
|
||||
const src = resolve(tempDir, 'client/project/fonts', v);
|
||||
const dest = resolve(tempDir, 'fonts');
|
||||
const plugin = Fontmin.glyph({
|
||||
text: [...chars].join('')
|
||||
});
|
||||
fontmin.src(src).dest(dest).use(plugin);
|
||||
return fontmin.runAsync();
|
||||
})
|
||||
).catch(reason => {
|
||||
logProgress(4, ProgressStatus.Fail);
|
||||
process.stderr.write(String(reason));
|
||||
process.exit(1);
|
||||
const chars = await getAllChars();
|
||||
const content = await buildContent({
|
||||
sourceDir: resolve(process.cwd(), 'src/content'),
|
||||
outputDir: resolve(distDir, 'content'),
|
||||
chars
|
||||
});
|
||||
|
||||
logProgress(5, ProgressStatus.Working);
|
||||
logProgress(4, ProgressStatus.Working);
|
||||
|
||||
//#region 资源分块
|
||||
const iconsFile = await readFile(
|
||||
resolve(process.cwd(), 'public/project/icons.js'),
|
||||
'utf-8'
|
||||
);
|
||||
const iconsObject: RequiredIconsData = JSON.parse(
|
||||
iconsFile.split('\n').slice(1).join('\n')
|
||||
);
|
||||
const resources = await splitResource(
|
||||
dataObject,
|
||||
iconsObject,
|
||||
resolve(tempDir, 'client'),
|
||||
resolve(tempDir, 'fonts'),
|
||||
RESOUCE_SIZE
|
||||
).catch(reason => {
|
||||
logProgress(5, ProgressStatus.Fail);
|
||||
process.stderr.write(String(reason));
|
||||
process.exit(1);
|
||||
});
|
||||
await copy(resolve(process.cwd(), 'LICENSE'), resolve(distDir, 'LICENSE'));
|
||||
|
||||
await Promise.all(
|
||||
resources.map(v =>
|
||||
writeFile(resolve(tempDir, 'resource', v.fileName), v.buffer)
|
||||
)
|
||||
).catch(reason => {
|
||||
logProgress(5, ProgressStatus.Fail);
|
||||
process.stderr.write(String(reason));
|
||||
process.exit(1);
|
||||
});
|
||||
logProgress(4, ProgressStatus.Success);
|
||||
|
||||
logProgress(6, ProgressStatus.Working);
|
||||
const dataStats = await stat(resolve(distDir, 'data.js'));
|
||||
const totalSize = await getDirectorySize(distDir);
|
||||
|
||||
//#region 最后处理
|
||||
|
||||
const toCopy = [
|
||||
'libs',
|
||||
'_server',
|
||||
'extensions',
|
||||
'index.html',
|
||||
'editor.html',
|
||||
'styles.css',
|
||||
'logo.png',
|
||||
'project/floors',
|
||||
'project/data.js',
|
||||
'project/enemys.js',
|
||||
'project/events.js',
|
||||
'project/functions.js',
|
||||
'project/icons.js',
|
||||
'project/items.js',
|
||||
'project/maps.js',
|
||||
'project/plugins.js',
|
||||
'project/materials/airwall.png'
|
||||
];
|
||||
|
||||
clientPackArr.forEach(v => {
|
||||
v.output.forEach(v => {
|
||||
toCopy.push(v.fileName);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
toCopy.map(v =>
|
||||
copy(resolve(tempDir, 'client', v), resolve(distDir, v))
|
||||
)
|
||||
);
|
||||
await copy(
|
||||
resolve(tempDir, 'resource'),
|
||||
resolve(distDir, 'project/resource')
|
||||
);
|
||||
await copy(
|
||||
resolve(tempDir, 'common/main.js'),
|
||||
resolve(distDir, 'main.js')
|
||||
);
|
||||
await copy(
|
||||
resolve(tempDir, 'data/data.iife.js'),
|
||||
resolve(distDir, 'data.process.js')
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
dataObject.main.bgms.map(v =>
|
||||
copy(
|
||||
resolve(tempDir, 'client/project/bgms', v),
|
||||
resolve(distDir, 'project/bgms', v)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const scripts = archiver('zip', {
|
||||
store: false,
|
||||
zlib: {
|
||||
level: 9
|
||||
}
|
||||
});
|
||||
scripts.directory(resolve(process.cwd(), 'packages'), 'packages/');
|
||||
scripts.directory(
|
||||
resolve(process.cwd(), 'packages-user'),
|
||||
'packages-user/'
|
||||
);
|
||||
scripts.directory(resolve(process.cwd(), 'src'), '/src');
|
||||
scripts.file(resolve(process.cwd(), 'public', 'main.js'), {
|
||||
name: 'main.js'
|
||||
});
|
||||
|
||||
const output = createWriteStream(resolve(distDir, 'source-code.zip'));
|
||||
scripts.pipe(output);
|
||||
|
||||
output.on('error', err => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
await new Promise<void>(res => {
|
||||
output.on('finish', () => res());
|
||||
scripts.finalize();
|
||||
});
|
||||
|
||||
const json = generateResourceJSON(resources);
|
||||
await writeFile(resolve(distDir, 'loadList.json'), json, 'utf-8');
|
||||
|
||||
await copy(
|
||||
resolve(process.cwd(), 'LICENSE'),
|
||||
resolve(distDir, 'LICENSE')
|
||||
);
|
||||
|
||||
await copy(
|
||||
resolve(process.cwd(), 'script/template/启动服务.exe'),
|
||||
resolve(distDir, '启动服务.exe')
|
||||
);
|
||||
|
||||
const bgPath = 'project/images/bg.jpg';
|
||||
|
||||
if (await pathExists(resolve(tempDir, 'client', bgPath))) {
|
||||
await copy(
|
||||
resolve(tempDir, 'client', bgPath),
|
||||
resolve(distDir, bgPath)
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logProgress(6, ProgressStatus.Fail);
|
||||
process.stderr.write(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logProgress(7, ProgressStatus.Working);
|
||||
|
||||
//#region 压缩游戏
|
||||
|
||||
try {
|
||||
await zip.compressDir(
|
||||
resolve(distDir),
|
||||
resolve(process.cwd(), 'dist.zip'),
|
||||
{
|
||||
compress: true,
|
||||
compressionLevel: 9
|
||||
}
|
||||
);
|
||||
|
||||
await emptyDir(tempDir);
|
||||
await rmdir(tempDir);
|
||||
} catch (e) {
|
||||
logProgress(7, ProgressStatus.Fail);
|
||||
process.stderr.write(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
//#region 输出构建信息
|
||||
|
||||
const sourceStats = await stat(resolve(distDir, 'source-code.zip'));
|
||||
const sourceSize = sourceStats.size;
|
||||
const zipStats = await stat(resolve(process.cwd(), 'dist.zip'));
|
||||
const zipSize = zipStats.size;
|
||||
const resourceSize = resources.reduce(
|
||||
(prev, curr) => prev + curr.byteLength,
|
||||
0
|
||||
);
|
||||
|
||||
process.stdout.write(ansi.clear);
|
||||
process.stdout.write(`✅ 构建已完成!\r\n`);
|
||||
if (zipSize > 100 * 2 ** 20) {
|
||||
process.stdout.write(
|
||||
`⚠️ 压缩包大于 100M,可能导致发塔困难,请考虑降低塔的大小\r\n`
|
||||
);
|
||||
const suggections: string[] = [];
|
||||
if (dataObject.main.bgms.some(v => !v.endsWith('opus'))) {
|
||||
suggections.push(`将 BGM 和音效换用 opus 格式`);
|
||||
}
|
||||
if (dataObject.main.images.some(v => !v.endsWith('webp'))) {
|
||||
suggections.push(`将图片换用无损 webp 格式`);
|
||||
}
|
||||
if (suggections.length > 0) {
|
||||
process.stdout.write(`降低压缩包体积的可能方案:\r\n`);
|
||||
suggections.forEach((v, i) => {
|
||||
process.stdout.write(`${i + 1}. ${v}\r\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
process.stdout.write(`压缩包大小:${formatSize(zipSize)}\r\n`);
|
||||
process.stdout.write(`源码大小:${formatSize(sourceSize)}\r\n`);
|
||||
process.stdout.write(`资源大小:${formatSize(resourceSize)}\r\n`);
|
||||
resources.forEach(v => {
|
||||
process.stdout.write(
|
||||
`--> ${v.fileName} ${formatSize(v.byteLength)} | ${v.content.length} 个资源\r\n`
|
||||
);
|
||||
});
|
||||
process.stdout.write(
|
||||
`数据端入口:data.js(${formatSize(dataStats.size)})\r\n`
|
||||
);
|
||||
process.stdout.write(
|
||||
`content:${content.files} 个文件,其中 ${content.jsonc} 个 jsonc、${content.fonts} 个字体\r\n`
|
||||
);
|
||||
process.stdout.write(`dist 体积:${formatSize(totalSize)}\r\n`);
|
||||
process.stdout.write(`游戏已经输出至 dist 文件夹\r\n`);
|
||||
process.stdout.write(`游戏已压缩至 dist.zip 压缩文件\r\n`);
|
||||
}
|
||||
|
||||
// Execute
|
||||
(() => {
|
||||
buildGame();
|
||||
})();
|
||||
buildGame().catch(reason => {
|
||||
process.stderr.write(String(reason));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const packagesDir = path.resolve('./packages');
|
||||
const outputDir = path.resolve('./dist/packages');
|
||||
|
||||
// 清空 dist 目录
|
||||
fs.emptyDirSync(outputDir);
|
||||
|
||||
// 获取所有包目录
|
||||
const packageDirs = fs.readdirSync(packagesDir).filter(name => {
|
||||
const packagePath = path.join(packagesDir, name);
|
||||
return fs.statSync(packagePath).isDirectory();
|
||||
});
|
||||
|
||||
// 构建每一个包
|
||||
async function buildPackages() {
|
||||
for (const packageName of packageDirs) {
|
||||
const packageDir = path.join(packagesDir, packageName);
|
||||
const configFile = path.resolve('./vite.config.ts');
|
||||
const config = await loadConfigFromFile(
|
||||
{ command: 'build', mode: 'production' },
|
||||
configFile
|
||||
);
|
||||
const resolved = mergeConfig(config?.config ?? {}, {
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.join(packageDir, 'src/index.ts'),
|
||||
name: packageName,
|
||||
formats: ['es'],
|
||||
fileName: format => `${packageName}.${format}.js`
|
||||
},
|
||||
outDir: path.join(outputDir, packageName),
|
||||
sourcemap: true,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
external: [/node_modules/, /^@motajs\/.*/]
|
||||
}
|
||||
},
|
||||
publicDir: false
|
||||
} satisfies UserConfig);
|
||||
|
||||
await build(resolved);
|
||||
console.log(`✅ Package ${packageName} built successfully.`);
|
||||
}
|
||||
}
|
||||
|
||||
buildPackages().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { build } from 'vite';
|
||||
import { RollupOutput } from 'rollup';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { dataIsolationPlugin } from './build-plugin';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
/** 创建独立的临时构建根目录,并登记以便用例结束后统一清理 */
|
||||
async function createRoot() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mota-data-isolation-'));
|
||||
tempDirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 在临时根目录下写入文件,必要时创建父目录,返回写入后的绝对路径 */
|
||||
async function writeRootFile(root: string, name: string, content: string) {
|
||||
const file = join(root, name);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, content, 'utf8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 以数据端库模式 ESM 配置执行一次真实的 vite 构建 */
|
||||
function buildFixture(root: string, entry: string) {
|
||||
return build({
|
||||
configFile: false,
|
||||
root,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
write: false,
|
||||
minify: false,
|
||||
copyPublicDir: false,
|
||||
lib: {
|
||||
entry,
|
||||
formats: ['es'],
|
||||
fileName: 'data'
|
||||
}
|
||||
},
|
||||
plugins: [dataIsolationPlugin()]
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行构建并返回错误信息,构建成功时返回空字符串 */
|
||||
async function getBuildError(root: string, entry: string) {
|
||||
try {
|
||||
await buildFixture(root, entry);
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const dirs = tempDirs.splice(0);
|
||||
await Promise.all(
|
||||
dirs.map(dir => rm(dir, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('dataIsolationPlugin', () => {
|
||||
// 直接引入 vue 说明符的数据端模块必须被插件拒绝
|
||||
it('rejects a render-side specifier', async () => {
|
||||
const root = await createRoot();
|
||||
const entry = await writeRootFile(root, 'entry.ts', "import 'vue';\n");
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: vue');
|
||||
expect(message).toContain(
|
||||
'data-side build must not depend on render-side'
|
||||
);
|
||||
});
|
||||
|
||||
// 相对引入的 src/client 渲染目录必须按解析路径被 moduleParsed 拒绝
|
||||
it('rejects a resolved src/client module', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'src/client/ui/fake.ts',
|
||||
'export const fake = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"import './src/client/ui/fake';\n"
|
||||
);
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: src/client');
|
||||
});
|
||||
|
||||
// 相对引入的 packages-user/client-* 必须按解析路径被 moduleParsed 拒绝
|
||||
it('rejects a resolved packages-user client package', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'packages-user/client-modules/fake.ts',
|
||||
'export const fake = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"import './packages-user/client-modules/fake';\n"
|
||||
);
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: packages-user/client-');
|
||||
});
|
||||
|
||||
// 纯数据端依赖必须通过构建并产出 ESM 代码
|
||||
it('passes a pure data-side graph and emits ESM', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'packages-user/data-common/fake.ts',
|
||||
'export const value = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"export { value } from './packages-user/data-common/fake';\n"
|
||||
);
|
||||
|
||||
const result = (await buildFixture(root, entry)) as
|
||||
| RollupOutput
|
||||
| RollupOutput[];
|
||||
const outputs = Array.isArray(result) ? result : [result];
|
||||
const code = outputs
|
||||
.flatMap(v => v.output)
|
||||
.filter(v => v.type === 'chunk')
|
||||
.map(v => v.code)
|
||||
.join('\n');
|
||||
|
||||
expect(code).toContain('export');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Plugin } from 'vite';
|
||||
import { ModuleInfo } from 'rollup';
|
||||
|
||||
/** 数据端构建中禁止出现的渲染端模块说明符,第一项为匹配规则,第二项为报错时的命中标记 */
|
||||
const SPECIFIER_BANS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/^@user\/client-/, '@user/client-'],
|
||||
[/^@user\/entry-client(?:\/|$)/, '@user/entry-client'],
|
||||
[/^@motajs\/render(?:[/-]|$)/, '@motajs/render'],
|
||||
[/^@motajs\/client(?:[/-]|$)/, '@motajs/client'],
|
||||
[/^@motajs\/system(?:[/-]|$)/, '@motajs/system'],
|
||||
[/^@motajs\/audio(?:[/-]|$)/, '@motajs/audio'],
|
||||
[/^@motajs\/legacy-/, '@motajs/legacy-'],
|
||||
[/^vue(?:\/|$)/, 'vue'],
|
||||
[/^@vue\//, '@vue/'],
|
||||
[/^ant-design-vue(?:\/|$)/, 'ant-design-vue'],
|
||||
[/^@ant-design\/icons-vue(?:\/|$)/, '@ant-design/icons-vue']
|
||||
];
|
||||
|
||||
/** 数据端构建中禁止出现的渲染端模块解析路径,第一项为匹配规则,第二项为报错时的命中标记 */
|
||||
const PATH_BANS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/[/\\]packages-user[/\\]client-/, 'packages-user/client-'],
|
||||
[/[/\\]packages[/\\]render(?:[/\\-]|$)/, 'packages/render'],
|
||||
[/[/\\]packages[/\\]render-vue(?:[/\\-]|$)/, 'packages/render-vue'],
|
||||
[/[/\\]packages[/\\]client(?:[/\\-]|$)/, 'packages/client'],
|
||||
[/[/\\]packages[/\\]system(?:[/\\-]|$)/, 'packages/system'],
|
||||
[/[/\\]packages[/\\]audio(?:[/\\-]|$)/, 'packages/audio'],
|
||||
[/[/\\]packages[/\\]legacy-/, 'packages/legacy-'],
|
||||
[/[/\\]src[/\\]client(?:[/\\]|$)/, 'src/client'],
|
||||
[
|
||||
/[/\\]node_modules[/\\](?:vue|@vue|ant-design-vue|@ant-design)(?:[/\\]|$)/,
|
||||
'node_modules'
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* 在禁令集合中查找命中的规则
|
||||
* @param bans 禁令集合
|
||||
* @param value 待匹配的模块说明符或解析路径
|
||||
*/
|
||||
function findBan(
|
||||
bans: ReadonlyArray<readonly [RegExp, string]>,
|
||||
value: string
|
||||
) {
|
||||
return bans.find(ban => ban[0].test(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数据端构建的隔离检查插件,禁止数据端模块图引用任何渲染端模块。
|
||||
* 说明符经 `resolveId` 拦截,别名解析后的真实路径经 `moduleParsed` 兜底。
|
||||
*/
|
||||
export function dataIsolationPlugin(): Plugin {
|
||||
return {
|
||||
name: 'mota-data-isolation',
|
||||
enforce: 'pre',
|
||||
apply: 'build',
|
||||
resolveId(source, importer) {
|
||||
const ban = findBan(SPECIFIER_BANS, source);
|
||||
if (ban) {
|
||||
this.error(
|
||||
`[mota-data-isolation] data-side build must not depend on render-side module "${source}" ` +
|
||||
`(matched: ${ban[1]}). Importer: ${importer ?? '<entry>'}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
moduleParsed(info: ModuleInfo) {
|
||||
const ban = findBan(PATH_BANS, info.id);
|
||||
if (!ban) return;
|
||||
const importers = this.getModuleInfo(info.id)!.importers;
|
||||
this.error(
|
||||
`[mota-data-isolation] data-side build must not depend on render-side module "${info.id}" ` +
|
||||
`(matched: ${ban[1]}). Importers: ${
|
||||
importers.length > 0 ? importers.join(', ') : '<entry>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
+101
-205
@@ -1,225 +1,121 @@
|
||||
import JSZip from 'jszip';
|
||||
import { RequiredData, RequiredIconsData } from './types';
|
||||
import { Stats } from 'fs';
|
||||
import { readdir, readFile, stat } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import { fileHash } from './utils';
|
||||
import Fontmin from 'fontmin';
|
||||
import { copy } from 'fs-extra';
|
||||
import { mkdir, readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { dirname, extname, join } from 'path';
|
||||
|
||||
export const enum CompressedUsage {
|
||||
// ---- 系统加载内容,不可更改
|
||||
Font,
|
||||
Image,
|
||||
Sound,
|
||||
Tileset,
|
||||
Autotile,
|
||||
Material,
|
||||
Animate
|
||||
/** 需要经 fontmin 做字形子集化的字体扩展名 */
|
||||
const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2'];
|
||||
|
||||
export interface IContentBuildOptions {
|
||||
/** content 素材的源目录 */
|
||||
readonly sourceDir: string;
|
||||
/** content 素材的输出目录 */
|
||||
readonly outputDir: string;
|
||||
/** 参与字体子集化的全部字符 */
|
||||
readonly chars: string;
|
||||
}
|
||||
|
||||
export const enum LoadDataType {
|
||||
ArrayBuffer,
|
||||
Uint8Array,
|
||||
Blob,
|
||||
Text,
|
||||
JSON
|
||||
export interface IContentBuildStats {
|
||||
/** 处理的文件总数 */
|
||||
readonly files: number;
|
||||
/** 压缩的 jsonc 文件数 */
|
||||
readonly jsonc: number;
|
||||
/** 压缩的字体文件数 */
|
||||
readonly fonts: number;
|
||||
}
|
||||
|
||||
export interface ResourceInfo {
|
||||
readonly name: string;
|
||||
readonly readAs: LoadDataType;
|
||||
readonly usage: CompressedUsage;
|
||||
readonly stats: Stats;
|
||||
interface IContentBuildCounter {
|
||||
files: number;
|
||||
jsonc: number;
|
||||
fonts: number;
|
||||
}
|
||||
|
||||
export interface SplittedResource {
|
||||
readonly byteLength: number;
|
||||
readonly resource: JSZip;
|
||||
readonly buffer: Uint8Array;
|
||||
readonly fileName: string;
|
||||
readonly content: Readonly<ResourceInfo>[];
|
||||
/**
|
||||
* 将 jsonc 文本压缩为单行纯 JSON,按行去掉全部 `//` 注释与系统标记行。
|
||||
* @param text jsonc 原文
|
||||
*/
|
||||
export function minifyJSONC(text: string): string {
|
||||
const body = text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !line.trim().startsWith('//'))
|
||||
.join('\n');
|
||||
return JSON.stringify(JSON.parse(body));
|
||||
}
|
||||
|
||||
interface ResourceContent extends ResourceInfo {
|
||||
readonly content: string | Buffer | Uint8Array;
|
||||
readonly exceed: boolean;
|
||||
/**
|
||||
* 将单个字体文件按给定字符集做字形子集化,输出到目标目录并保留原文件名。
|
||||
* @param source 字体源文件
|
||||
* @param target 目标文件路径
|
||||
* @param chars 参与子集化的全部字符
|
||||
*/
|
||||
async function compressFont(source: string, target: string, chars: string) {
|
||||
const fontmin = new Fontmin();
|
||||
const glyph = Fontmin.glyph({ text: chars });
|
||||
await fontmin.src(source).dest(dirname(target)).use(glyph).runAsync();
|
||||
}
|
||||
|
||||
interface ResourcePath {
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly usage: CompressedUsage;
|
||||
}
|
||||
|
||||
function getTypeByUsage(usage: CompressedUsage): LoadDataType {
|
||||
switch (usage) {
|
||||
case CompressedUsage.Animate:
|
||||
return LoadDataType.Text;
|
||||
case CompressedUsage.Autotile:
|
||||
case CompressedUsage.Image:
|
||||
case CompressedUsage.Tileset:
|
||||
case CompressedUsage.Material:
|
||||
return LoadDataType.Blob;
|
||||
case CompressedUsage.Font:
|
||||
case CompressedUsage.Sound:
|
||||
return LoadDataType.ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
function getZipFolderByUsage(usage: CompressedUsage): string {
|
||||
switch (usage) {
|
||||
case CompressedUsage.Image:
|
||||
return 'image';
|
||||
case CompressedUsage.Tileset:
|
||||
return 'tileset';
|
||||
case CompressedUsage.Autotile:
|
||||
return 'autotile';
|
||||
case CompressedUsage.Material:
|
||||
return 'material';
|
||||
case CompressedUsage.Font:
|
||||
return 'font';
|
||||
case CompressedUsage.Sound:
|
||||
return 'sound';
|
||||
case CompressedUsage.Animate:
|
||||
return 'animate';
|
||||
}
|
||||
}
|
||||
|
||||
function readFileOfType(path: string, type: LoadDataType) {
|
||||
if (type === LoadDataType.Text) {
|
||||
return readFile(path, 'utf-8');
|
||||
} else {
|
||||
return readFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function compressFiles(files: ResourceContent[]) {
|
||||
const zip = new JSZip();
|
||||
files.forEach(v => {
|
||||
const dir = `${getZipFolderByUsage(v.usage)}/${v.name}`;
|
||||
zip.file(dir, v.content);
|
||||
});
|
||||
const buffer = await zip.generateAsync({
|
||||
type: 'uint8array',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: {
|
||||
level: 9
|
||||
}
|
||||
});
|
||||
|
||||
const hash = fileHash(buffer);
|
||||
const name = `resource.${hash}.h5data`;
|
||||
|
||||
const resource: SplittedResource = {
|
||||
byteLength: buffer.byteLength,
|
||||
buffer: buffer,
|
||||
resource: zip,
|
||||
fileName: name,
|
||||
content: files
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
export async function splitResource(
|
||||
data: RequiredData,
|
||||
icons: RequiredIconsData,
|
||||
base: string,
|
||||
fontsDir: string,
|
||||
limit: number
|
||||
/**
|
||||
* 递归处理目录:jsonc 压缩为纯 JSON,字体做子集化,其它素材原样拷贝。
|
||||
* @param sourceDir 源目录
|
||||
* @param outputDir 输出目录
|
||||
* @param chars 参与字体子集化的全部字符
|
||||
* @param counter 统计计数器
|
||||
*/
|
||||
async function processDirectory(
|
||||
sourceDir: string,
|
||||
outputDir: string,
|
||||
chars: string,
|
||||
counter: IContentBuildCounter
|
||||
) {
|
||||
const result: SplittedResource[] = [];
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||
|
||||
// 获取所有需要分块的资源
|
||||
const { animates, fonts, images, sounds, tilesets } = data.main;
|
||||
const autotiles = Object.keys(icons.autotile);
|
||||
const materials = await readdir(resolve(base, 'project/materials'));
|
||||
for (const entry of entries) {
|
||||
const source = join(sourceDir, entry.name);
|
||||
const target = join(outputDir, entry.name);
|
||||
|
||||
const paths: ResourcePath[] = [
|
||||
...animates.map<ResourcePath>(v => ({
|
||||
name: `${v}.animate`,
|
||||
path: resolve(base, 'project/animates', `${v}.animate`),
|
||||
usage: CompressedUsage.Animate
|
||||
})),
|
||||
...fonts.map<ResourcePath>(v => ({
|
||||
name: v,
|
||||
path: resolve(fontsDir, v),
|
||||
usage: CompressedUsage.Font
|
||||
})),
|
||||
...images.map<ResourcePath>(v => ({
|
||||
name: v,
|
||||
path: resolve(base, 'project/images', v),
|
||||
usage: CompressedUsage.Image
|
||||
})),
|
||||
...sounds.map<ResourcePath>(v => ({
|
||||
name: v,
|
||||
path: resolve(base, 'project/sounds', v),
|
||||
usage: CompressedUsage.Sound
|
||||
})),
|
||||
...tilesets.map<ResourcePath>(v => ({
|
||||
name: v,
|
||||
path: resolve(base, 'project/tilesets', v),
|
||||
usage: CompressedUsage.Tileset
|
||||
})),
|
||||
...autotiles.map<ResourcePath>(v => ({
|
||||
name: `${v}.png`,
|
||||
path: resolve(base, 'project/autotiles', `${v}.png`),
|
||||
usage: CompressedUsage.Autotile
|
||||
})),
|
||||
...materials.map<ResourcePath>(v => ({
|
||||
name: v,
|
||||
path: resolve(base, 'project/materials', v),
|
||||
usage: CompressedUsage.Material
|
||||
}))
|
||||
];
|
||||
if (entry.isDirectory()) {
|
||||
await processDirectory(source, target, chars, counter);
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
paths.map(async ({ path, usage, name }) => {
|
||||
const stats = await stat(path);
|
||||
if (!stats.isFile()) {
|
||||
return Promise.reject(
|
||||
new ReferenceError(
|
||||
`Expected resource is a file, but get directory.`
|
||||
)
|
||||
);
|
||||
}
|
||||
const type = getTypeByUsage(usage);
|
||||
const content = await readFileOfType(path, type);
|
||||
const info: ResourceContent = {
|
||||
readAs: type,
|
||||
name,
|
||||
usage,
|
||||
stats,
|
||||
content,
|
||||
exceed: stats.size > limit
|
||||
};
|
||||
return info;
|
||||
})
|
||||
const extension = extname(entry.name);
|
||||
if (extension === '.jsonc') {
|
||||
const text = await readFile(source, 'utf-8');
|
||||
await writeFile(target, minifyJSONC(text), 'utf-8');
|
||||
counter.jsonc += 1;
|
||||
} else if (FONT_EXTENSIONS.includes(extension)) {
|
||||
await compressFont(source, target, chars);
|
||||
counter.fonts += 1;
|
||||
} else {
|
||||
await copy(source, target);
|
||||
}
|
||||
counter.files += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 content 素材从源目录构建到输出目录,jsonc 压缩为纯 JSON,字体做子集化。
|
||||
* @param options 构建选项
|
||||
*/
|
||||
export async function buildContent(
|
||||
options: IContentBuildOptions
|
||||
): Promise<IContentBuildStats> {
|
||||
const counter: IContentBuildCounter = { files: 0, jsonc: 0, fonts: 0 };
|
||||
await processDirectory(
|
||||
options.sourceDir,
|
||||
options.outputDir,
|
||||
options.chars,
|
||||
counter
|
||||
);
|
||||
|
||||
// 从小到大排序,这样的话可以尽量减小资源分块文件数量
|
||||
files.sort((a, b) => a.stats.size - b.stats.size);
|
||||
|
||||
let index = 0;
|
||||
while (index < files.length) {
|
||||
let total = 0;
|
||||
const start = index;
|
||||
let i = index;
|
||||
for (; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.exceed) {
|
||||
if (i === index) i = index + 1;
|
||||
break;
|
||||
} else {
|
||||
total += file.stats.size;
|
||||
}
|
||||
if (total > limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
index = i;
|
||||
const toZip = files.slice(start, index);
|
||||
result.push(await compressFiles(toZip));
|
||||
if (counter.fonts === 0) {
|
||||
process.stdout.write('⚠️ 未找到可压缩的字体文件,已跳过字体压缩\r\n');
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
files: counter.files,
|
||||
jsonc: counter.jsonc,
|
||||
fonts: counter.fonts
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user