mirror of
https://github.com/unanmed/HumanBreak.git
synced 2025-09-02 21:31:47 +08:00
664 lines
19 KiB
TypeScript
664 lines
19 KiB
TypeScript
import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite';
|
||
import legacy from '@vitejs/plugin-legacy';
|
||
import { resolve } from 'path';
|
||
import { copy, emptyDir, ensureDir } 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, ResourceType } from './types';
|
||
import { splitResource, SplittedResource } from './build-resource';
|
||
import { formatSize } from './utils';
|
||
|
||
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');
|
||
|
||
/**
|
||
* 构建游戏代码
|
||
* @param entry 入口文件
|
||
*/
|
||
async function buildClient(outDir: string) {
|
||
const configFile = resolve(process.cwd(), 'vite.config.ts');
|
||
const config = await loadConfigFromFile(
|
||
{ command: 'build', mode: 'production' },
|
||
configFile
|
||
);
|
||
const resolved = mergeConfig(config?.config ?? {}, {
|
||
plugins: [
|
||
legacy({
|
||
targets: [
|
||
'Chrome >= 56',
|
||
'Firefox >= 51',
|
||
'Edge >= 79',
|
||
'Safari >= 15',
|
||
'Opera >= 43'
|
||
],
|
||
polyfills: true,
|
||
modernPolyfills: true,
|
||
renderModernChunks: false
|
||
})
|
||
],
|
||
build: {
|
||
outDir,
|
||
copyPublicDir: true,
|
||
rollupOptions: {
|
||
external: ['@wasm-audio-decoders/opus-ml'],
|
||
output: {
|
||
format: 'es',
|
||
entryFileNames: '[name].[hash].js',
|
||
chunkFileNames: 'chunks/[name].[hash].js',
|
||
assetFileNames: 'assets/[name].[hash][extname]',
|
||
manualChunks: {
|
||
antdv: ['ant-design-vue', '@ant-design/icons-vue'],
|
||
common: [
|
||
'lodash-es',
|
||
'axios',
|
||
'lz-string',
|
||
'chart.js',
|
||
'mutate-animate',
|
||
'eventemitter3',
|
||
'gl-matrix',
|
||
'jszip',
|
||
'anon-tokyo',
|
||
'vue'
|
||
],
|
||
audio: [
|
||
'codec-parser',
|
||
'opus-decoder',
|
||
'ogg-opus-decoder',
|
||
'@wasm-audio-decoders/ogg-vorbis'
|
||
]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} satisfies UserConfig);
|
||
|
||
return build({
|
||
...resolved,
|
||
configFile: false
|
||
});
|
||
}
|
||
|
||
async function buildData(outDir: string, entry: string) {
|
||
const configFile = resolve(process.cwd(), 'vite.config.ts');
|
||
const config = await loadConfigFromFile(
|
||
{ command: 'build', mode: 'production' },
|
||
configFile
|
||
);
|
||
const resolved = mergeConfig(config?.config ?? {}, {
|
||
build: {
|
||
outDir,
|
||
copyPublicDir: false,
|
||
lib: {
|
||
entry,
|
||
name: 'ProcessData',
|
||
fileName: 'data',
|
||
formats: ['iife']
|
||
}
|
||
}
|
||
} satisfies UserConfig);
|
||
|
||
return build({
|
||
...resolved,
|
||
configFile: false
|
||
});
|
||
}
|
||
|
||
const enum ProgressStatus {
|
||
Success,
|
||
Fail,
|
||
Working,
|
||
Warn
|
||
}
|
||
|
||
/**
|
||
* 输出构建步骤
|
||
* @param step 步骤数
|
||
* @param final 最后一步的状态
|
||
*/
|
||
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>
|
||
) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
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[]) {
|
||
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'));
|
||
|
||
const floors = await readdir(resolve(tempDir, 'client/project/floors'));
|
||
const ids = floors.map(v => resolve(tempDir, 'client/project/floors', v));
|
||
files.push(...ids);
|
||
|
||
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;
|
||
}
|
||
|
||
interface CompressedLoadListItem {
|
||
type: ResourceType;
|
||
name: string;
|
||
usage: string;
|
||
}
|
||
|
||
type CompressedLoadList = Record<string, CompressedLoadListItem[]>;
|
||
|
||
/**
|
||
* 生成资源地图 json 文件
|
||
*/
|
||
function generateResourceJSON(resources: SplittedResource[]) {
|
||
const list: CompressedLoadList = {};
|
||
|
||
resources.forEach(file => {
|
||
const uri = `project/resource/${file.fileName}`;
|
||
file.content.forEach(content => {
|
||
const item: CompressedLoadListItem = {
|
||
type: content.type,
|
||
name: content.name,
|
||
usage: content.usage
|
||
};
|
||
list[uri] ??= [];
|
||
list[uri].push(item);
|
||
});
|
||
});
|
||
|
||
return JSON.stringify(list);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
);
|
||
|
||
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;
|
||
|
||
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;
|
||
await writeFile(
|
||
resolve(tempDir, 'common/main.js'),
|
||
head + '\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}.ttf`);
|
||
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);
|
||
});
|
||
|
||
logProgress(5, 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 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(6, ProgressStatus.Working);
|
||
|
||
//#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'
|
||
];
|
||
|
||
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');
|
||
scripts.directory('packages/', resolve(process.cwd(), 'packages'));
|
||
scripts.directory(
|
||
'packages-user/',
|
||
resolve(process.cwd(), 'packages-user')
|
||
);
|
||
scripts.directory('src/', resolve(process.cwd(), 'src'));
|
||
|
||
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')
|
||
);
|
||
} 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')
|
||
);
|
||
|
||
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`
|
||
);
|
||
}
|
||
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`
|
||
);
|
||
});
|
||
}
|
||
|
||
// Execute
|
||
(() => {
|
||
buildGame();
|
||
})();
|