refactor: 构建脚本

This commit is contained in:
unanmed 2026-10-20 19:24:55 +08:00
parent d35f8303da
commit 3cc97e5ed3
8 changed files with 672 additions and 833 deletions

View File

@ -0,0 +1,195 @@
---
phase: 09-minimum-browser-runnable
plan: 21
subsystem: build
tags: [vite, rollup, esm, jsonc, fontmin, data-isolation, build-pipeline]
# Dependency graph
requires:
- phase: 09-minimum-browser-runnable
provides: 09-19 / 09-20 的数据端与渲染端插件系统(构建脚本此前未适配)
provides:
- "script/build-plugin.ts:`dataIsolationPlugin`(模块图双路隔离检测)"
- "script/build-resource.ts:`buildContent` / `minifyJSONC` 与 content 构建接口"
- "script/build-game.ts:准备 → 渲染端 → 数据端 → content → LICENSE 的新构建编排"
affects: [09-minimum-browser-runnable, build:game 产物布局, 后续 content/loader 适配]
# Actuals
actuals:
tokens: 4647
tasks: 3
commits: 0
# Tech tracking
tech-stack:
added: []
patterns:
- "数据端隔离检测:`resolveId` 拦说明符 + `moduleParsed(info: ModuleInfo)` 按 `info.id` 兜底,命中即 `this.error`"
- "数据端库模式 ESM:入口产物 `dist/data.js`(dist 根),附属 chunk/asset 落在 `dist/data/`,与渲染端 `assets/`、`chunks/` 分离"
- "content 构建:jsonc 按行去 `//` 注释后 `JSON.parse` + `JSON.stringify`,输出单行纯 JSON 且后缀仍为 `.jsonc`"
key-files:
created:
- script/build-plugin.ts
- script/build-plugin.test.ts
modified:
- script/build-resource.ts
- script/build-game.ts
deleted:
- script/build-packages.ts
key-decisions:
- "按用户指定文件名落码:`script/build-plugin.ts` / `script/build-plugin.test.ts`(覆盖 dev.md 小驼峰文件名规则)"
- "隔离检测改为模块图硬报错,删除按体积推断的 `checkClientData` 启发式"
- "数据端入口固定 `dist/data.js`(dist 根),附属产物前缀 `data/`,并强制 `emptyOutDir: false`"
- "content 的 jsonc 去掉全部注释(含系统标记行),不保留注释、不改后缀、不改 loader"
patterns-established:
- "构建脚本按函数切片做结构门禁:buildClient / buildData / buildGame 各自断言关键配置"
- "插件行为用真实 `vite.build()`(`write: false` + 临时 fixture)验证,而不是只做文本断言"
requirements-completed: []
duration: 6min
completed: 2026-10-20
status: complete
---
# Phase 09 Plan 21: 任务 11 · 构建重构(适配新引擎)Summary
**构建脚本完成新引擎适配:渲染端以 `index.html` 输出常规 `dist`,数据端以库模式 ESM 产出 `dist/data.js` + `dist/data/`(模块图插件硬报错),`src/content` 去注释压成纯 JSON 落到 `dist/content`,并保留 `LICENSE`;旧 babel / zip / 分块 / 旧模板拷贝流程全部移除,`build-packages.ts` 已删除**
## Performance
- **Duration:** 约 6 分钟
- **Started:** 2026-10-20T17:31:00Z
- **Completed:** 2026-10-20T17:37:00Z
- **Tasks:** 3(Task 0 汇报关卡、Task 1 插件与单测、Task 2 脚本重写)
- **Files modified:** 5(新增 2、重写 2、删除 1)
## 任务执行结果
| Task | 内容 | 结果 |
| ---- | ---- | ---- |
| Task 0 | 预执行汇报关卡(`blocking-human`) | 用户已回复「可以执行」,关卡闭环 |
| Task 1 | `dataIsolationPlugin` + 真实 `vite.build()` 单测 | 完成,单测 4/4 通过 |
| Task 2 | 重写 `build-resource` / `build-game`、删除 `build-packages.ts`、写本 SUMMARY | 完成,结构门禁全绿 |
## What Was Built
### 数据端隔离插件(`script/build-plugin.ts`)
- 导出 `dataIsolationPlugin(): Plugin`;`name: 'mota-data-isolation'`、`enforce: 'pre'`、`apply: 'build'`。
- 双路扫描:
- `resolveId(source, importer)` 拦渲染端说明符(`@user/client-*`、`@user/entry-client`、`@motajs/render*`、`@motajs/client*`、`@motajs/system`、`@motajs/audio`、`@motajs/legacy-*`、`vue`、`@vue/*`、`ant-design-vue`、`@ant-design/icons-vue`)。
- `moduleParsed(info: ModuleInfo)` 用 `info.id` 拦解析后路径(`packages-user/client-*`、`packages/render*`、`packages/client*`、`packages/system`、`packages/audio`、`packages/legacy-*`、`src/client/**`、`node_modules/{vue,@vue,ant-design-vue,@ant-design}`),导入者经 `this.getModuleInfo(info.id)` 读取 `importers`。
- 命中即 `this.error(...)`,消息以 `[mota-data-isolation]` 开头,含 `matched: <token>`、命中模块与导入者,并说明 `data-side build must not depend on render-side`。
- 数据端依赖(`@user/data-*`、`@motajs/common`、`@motajs/loader`、`@motajs/anon-tokyo`、`@motajs/types`、`@motajs/animate`)不在禁令内。
- **取代** `checkClientData` / `ClientDataLevel` / `getFileName` / `getFileSize` 体积启发式。
### 渲染端(`buildClient`)
- 复用 `vite.config.ts`(`loadConfigFromFile` + `mergeConfig`),保持 `legacy(...)` 与 `manualChunks` 现状。
- **不设置** `rollupOptions.input`,由 Vite 默认以 `index.html` 为入口;`index.html` 本身未改。
- `outDir = dist`、`copyPublicDir: false`、`emptyOutDir: false`;输出常规 `index.html` + `assets/` / `chunks/`。
- 不再拷贝 `editor.html` / `_server` / `_docs` / `libs` / `logo.png` / `styles.css` / `main.js` / `启动服务.exe`,也不再做 `main.js` 的 babel 二次压缩。
### 数据端(`buildData`)
- 入口 `src/data.ts`,`lib.formats: ['es']`(ESM,不再 iife)。
- `entryFileNames: 'data.js'` → 入口产物固定 **`dist/data.js`(dist 根)**。
- `chunkFileNames: 'data/[name].[hash].js'`、`assetFileNames: 'data/[name].[hash][extname]'` → 附属产物落在 **`dist/data/`**,与渲染端 `assets/`、`chunks/` 分离。
- `copyPublicDir: false`、`emptyOutDir: false`(不清掉先产出的渲染端产物);`plugins: [dataIsolationPlugin()]`。
### content(`script/build-resource.ts`)
- `minifyJSONC(text)`:按行过滤 `line.trim().startsWith('//')`(去掉 `// Type: ...` 与全部系统标记行),`JSON.parse` + `JSON.stringify` 输出单行纯 JSON,非法 JSON 直接抛错;后缀仍 `.jsonc`。
- `buildContent(options)`:递归镜像 `src/content` → `dist/content`;jsonc 压缩、字体(`.ttf/.otf/.woff/.woff2`)经 `Fontmin.glyph({ text: chars })` 子集化、其它素材 `fs-extra.copy` 原样拷贝;无字体二进制时跳过并 `process.stdout.write` 告警。
- 接口:`IContentBuildOptions`(`sourceDir` / `outputDir` / `chars: string`)、`IContentBuildStats`(`files` / `jsonc` / `fonts`)。
- 不再分块、不再 zip、不再引用 JSZip / `./types`。
### 编排(`buildGame`)
1. 准备:`ensureDir` + `emptyDir` 清空 `dist` 与 `_temp`(全流程仅一次)。
2. `await buildClient(distDir)`。
3. `await buildData(distDir, resolve(process.cwd(), 'src/data.ts'))`。
4. `getAllChars(): Promise<string>`(读取 `dist` 下渲染端 `.js`,跳过 `dist/data`、`dist/content`;再读 `src/content/**/*.jsonc`)→ `buildContent({ sourceDir: 'src/content', outputDir: 'dist/content', chars })`。
5. 复制 `LICENSE` → `dist/LICENSE`,输出 `data.js` 体积、content 统计与 `dist` 总体积。
### 最终 `dist` 清单 / 排除项
- **产出:** `index.html` + Vite 常规渲染产物(`assets/` / `chunks/`)+ `data.js` + `data/`(数据端附属)+ `content/` + `LICENSE`。
- **不产出:** `public/**`(`copyPublicDir: false`)、`main.js`、`editor.html`、`_server`、`_docs`、`libs`、`logo.png`、`styles.css`、`启动服务.exe`、`source-code.zip`、`loadList.json`、`dist.zip`、资源分块 `resource.<hash>.h5data`。
### 删除清单
- 删除 `script/build-packages.ts`(D-93 / D-98)。
- `script/build-lib.ts` **未动**。
- `package.json` **未改**,故 `build:packages` script 会指向已删除文件而失效(按计划接受,属已知后续项)。
## Files Created/Modified
- `script/build-plugin.ts`(新增)— 数据端隔离 Vite 插件 `dataIsolationPlugin`
- `script/build-plugin.test.ts`(新增,必需)— 真实 `vite.build()` 行为单测
- `script/build-resource.ts`(重写)— content 构建模块 `minifyJSONC` / `buildContent`
- `script/build-game.ts`(重写)— 新构建编排
- `script/build-packages.ts`(删除)
**未改动(已核对):** `script/build-lib.ts`、`package.json`、`index.html`、`dev.md`、`src/**`、`public/**`、`packages-user/**`、`vite.config.ts`、`pnpm-lock.yaml`、`packages*/**`、`script/dev.ts`。
## 命名获批情形
- OI-1:`script/build-plugin.ts`(用户显式指定,覆盖 dev.md 小驼峰文件名规则)、`dataIsolationPlugin`、`name: 'mota-data-isolation'`、`script/build-plugin.test.ts`。
- OI-2:`buildContent`、`minifyJSONC`、`IContentBuildOptions`、`IContentBuildStats`。
- 另有 1 个不导出的内部接口 `IContentBuildCounter`(仅用于统计累加,非公共命名)。
## Gates Run
| 门禁 | 命令 | 结果 |
| ---- | ---- | ---- |
| 插件单测 | `cmd /c "pnpm exec vitest run script/build-plugin.test.ts"` | **PASS**(4/4:拒 `vue`、拒 `src/client`、拒 `packages-user/client-*`、纯数据端通过并产出 ESM) |
| 插件结构断言 | 计划 Task 1 `<automated>`(`PLUGIN GATE FAILED`) | **PASS** |
| build-resource 结构断言 | 计划 Task 2 `<automated>`(`BUILD RESOURCE GATE FAILED`) | **PASS** |
| build-game 按函数切片断言 | 计划 Task 2 `<automated>`(`BUILD GAME GATE FAILED`) | **PASS** |
| 文件清单断言 | 计划 Task 2 `<automated>`(`FILES GATE FAILED`) | **PASS** |
| 附加:jsonc 数据体检 | 对 `src/content` 23 个 jsonc 按行去注释后 `JSON.parse` | **PASS**(23/23 可压成纯 JSON,无注释残留) |
| 附加:语法 / 类型 | `tsc --noEmit --ignoreConfig ...`(仅 4 个脚本) | **PASS**(无输出) |
## Open Items 处置
- OI-1 / OI-2:按计划提议命名采用(Task 0 已报备并由用户批准执行)。
- 已知后续项(不在本计划):见下方 INFO-1 / INFO-2。
## Known Boundaries
- **INFO-1(jsonc 去净注释 ↔ 运行期加载器冲突):** `dist/content/**/*.jsonc` 去掉全部注释后不再包含系统标记行,而运行期 `PrefixedJSONCProcessor`(`packages-user/data-state/src/loader/jsoncProcessor.ts`)按 `prefixSplit` 切分原文,切分结果不是 2 段时会报 **error 66**。loader 适配经用户确认**不在本计划范围**,此处仅记录为已知后续项;本计划未保留注释、未改 loader、未改 `src/**` 与 `packages-user/**`。
- **INFO-2(样式文件):** `index.html` 仍含 `<link id="mota-css" href="/styles.css">`,本计划**不产出** `styles.css`,构建后该 link 会 404;`index.html` 与样式接线由用户处理,本计划未改该文件。
- **INFO-4(验证边界):** 本计划**未端到端运行全量 `build:game`**(渲染端仍有两处用户 WIP 的 stale import:`src/client/initialize.ts`、`src/client/plugin/shop/shop.tsx`)。验证仅覆盖插件单测 + 两个脚本的结构断言 + jsonc 数据体检 + 脚本级语法/类型检查。**不得**将本次结果表述为「全量构建已通过」。
## Deviations from Plan
无功能性偏差,计划按既定口径执行。两处执行细节说明:
1. **换行符:** 4 个脚本文件按 dev.md「换行使用 CRLF」与 `.prettierrc` 的 `endOfLine: crlf` 写成 CRLF(与仓库既有脚本一致);SUMMARY 等规划文档沿用 `.planning` 既有的 LF。
2. **单测产物形态:** `vite.build()` 在库模式下返回 `RollupOutput[]`,单测按数组/单对象两种形态归一后断言 `export`,未改动插件行为。
## Issues Encountered
- 单测首轮「纯数据端通过」用例因 `build()` 返回数组导致 `result.output` 为 `undefined`,已在测试内做形态归一后通过;插件实现未变。
## User Setup Required
None。
## Commits
**未提交任何内容(`git add` / `git commit` 均未执行,按本次执行指令保留在工作树)。**
## Next Phase Readiness
- 构建脚本已按新引擎产出结构就位,可继续由用户处理:INFO-1 的 jsonc loader 适配、INFO-2 的 `styles.css` 接线、渲染端两处 stale import。
- `package.json` 的 `build:packages` 已指向被删除的脚本,需在后续任务中调整(本计划按约束未改)。
---
*Phase: 09-minimum-browser-runnable*
*Completed: 2026-10-20*

View File

@ -197,6 +197,19 @@
- **D-91(范围边界):** 仅实现插件系统机制本身;不改入口/接线;不改 `initializeL0..L3` 等 config 字段口径(用户自行处理);不新增依赖;遵循 `dev.md`(无循环引用、命名、jsDoc、无副作用模块)。
- **D-92(插件错误处理与日志码,修订 D-87):** 插件系统用**新错误码**(`packages/common/src/logger.json` error 段新增):**循环依赖 → 76**;**依赖不存在的插件 → 77**;**重复 id → 78**。**重复 id 不覆盖原插件**(保留先注册者,重复者不注册)。**报错的插件全部不进入加载流程**(`awake`/`initialize`/`afterLoad` 均不执行;其下游依赖者亦不加载)。取代 D-87 中「复用 error 75」的口径;错误消息文本待用户批准。(2026-10-14 用户裁定)
### 任务 11(当前规划单元:构建重构)
> 本节界定**本次规划运行**的范围:任务 11 = 重构构建脚本以适配新引擎(`script/build-game.ts` / `script/build-resource.ts`),删除 `build-packages.ts` 与 `build-lib.ts`。`script/dev.ts` **不在范围**。
- **D-93(范围):** 重构 `script/build-game.ts` 与 `script/build-resource.ts`;**删除** `script/build-packages.ts` 与 `script/build-lib.ts`(后者为 vite-plugin-dts 遗留,不需要类型声明);`script/dev.ts` 不在本次范围。
- **D-94(数据端/渲染端分离 + Vite 插件检测):** 保证**数据端独立包不得引用渲染端**。写一个 **Vite 插件**在构建数据端时扫描其模块图,若含渲染端模块(`@user/client-*`、`@motajs/render*`、`vue` 等)则**报错并终止**构建。**取代**现有按构建体积推断的 `checkClientData` 启发式。
- **D-95(数据端产物):** 数据端构建为**库模式**、输出 **ESM**(**不用 iife**),入口 `src/data.ts`,产物 `dist/data/data.js`。
- **D-96(content 处理):** 构建后把 `src/content` 复制到 `dist/content`(去掉 `src` 前缀);做**简单压缩**——**jsonc** 用 `JSON.parse + JSON.stringify` 压缩(**去掉注释**,**后缀仍保留 `.jsonc`** 以确保加载不出错);**字体**用 fontmin 压缩;**不做资源分块**(不生成 `resource.<hash>.h5data`),**不做 zip 压缩**。`build-resource` 改为从 `src/content` 读取素材。
- **D-97(删除旧模板内容):** **不保留** `public/**`(即将整体删除)、`editor.html`、`_server`、`_docs`、`libs`、`logo.png`、`styles.css`、`main.js`、`script/template/启动服务.exe`;**不打包** `source-code.zip`;**不加** `loadList.json`;**保留 `LICENSE`(LGPL v3)**。`index.html` 去掉旧 `<link href='/styles.css'>`,样式改由入口经 JS 打包引入。
- **D-98(脚本与 npm script 调整):** 删除 `build-packages.ts` / `build-lib.ts` 及 `package.json` 中对应 `build:packages` / `build:lib` script;`build:game` 保留并调整(同时产出渲染端游戏与数据端独立包)。
- **D-99(类型检查前置):** 构建仍**前置 `vue-tsc --noEmit`**(保留)。
- **D-100(渲染端构建微调):** 移除 main.js 二次压缩(babel)步骤(`main.js` 不留);legacy 插件与 `manualChunks` 按新引擎实际情况微调。
### the agent's Discretion
- 无。用户明确未授权 AI 自行决定 UI 形态或任务范围。

View File

@ -674,12 +674,6 @@ importers:
'@user/data-state':
specifier: workspace:*
version: link:../packages-user/data-state
'@user/entry-client':
specifier: workspace:*
version: link:../packages-user/entry-client
'@user/entry-data':
specifier: workspace:*
version: link:../packages-user/entry-data
packages:

View File

@ -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);
});

View File

@ -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);
});

144
script/build-plugin.test.ts Normal file
View File

@ -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');
});
});

78
script/build-plugin.ts Normal file
View File

@ -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>'
}`
);
}
};
}

View File

@ -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
};
}