mirror of
https://github.com/motajs/template.git
synced 2026-10-21 23:00:29 +08:00
refactor: 构建脚本
This commit is contained in:
parent
d35f8303da
commit
3cc97e5ed3
195
.planning/phases/09-minimum-browser-runnable/09-21-SUMMARY.md
Normal file
195
.planning/phases/09-minimum-browser-runnable/09-21-SUMMARY.md
Normal 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*
|
||||
@ -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 形态或任务范围。
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
144
script/build-plugin.test.ts
Normal file
144
script/build-plugin.test.ts
Normal 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
78
script/build-plugin.ts
Normal 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>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user