init: 2.B

This commit is contained in:
AncTe committed 2026-02-28 20:23:34 +08:00
commit 6bf32174d8
610 files changed
+151444

No files matched your search

+682
View File
@@ -0,0 +1,682 @@
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, ResourceType } from './types';
import { splitResource, SplittedResource } from './build-resource';
import { formatSize } from './utils';
const DEBUG_REPLAY = false;
const ansi = {
clear: '\x1b[2J\x1b[0f'
};
// 资源分离步骤的单包大小,默认 2M,可以自行调整
const RESOUCE_SIZE = 2 * 2 ** 20;
const distDir = resolve(process.cwd(), 'dist');
const tempDir = resolve(process.cwd(), '_temp');
/**
* 构建游戏代码
* @param entry 入口文件
*/
async function buildClient(outDir: string) {
const configFile = resolve(process.cwd(), 'vite.config.ts');
const config = await loadConfigFromFile(
{ command: 'build', mode: 'production' },
configFile
);
const resolved = mergeConfig(config?.config ?? {}, {
plugins: [
legacy({
targets: [
'Chrome >= 56',
'Firefox >= 51',
'Edge >= 79',
'Safari >= 15',
'Opera >= 43'
],
polyfills: true,
renderModernChunks: false
})
],
build: {
outDir,
copyPublicDir: true,
rollupOptions: {
external: ['@wasm-audio-decoders/opus-ml'],
output: {
format: 'es',
entryFileNames: '[name].[hash].js',
chunkFileNames: 'chunks/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]',
manualChunks: {
antdv: ['ant-design-vue', '@ant-design/icons-vue'],
audio: [
'codec-parser',
'opus-decoder',
'ogg-opus-decoder',
'@wasm-audio-decoders/ogg-vorbis'
]
}
}
}
}
} satisfies UserConfig);
return build({
...resolved,
configFile: false
});
}
async function buildData(outDir: string, entry: string) {
const configFile = resolve(process.cwd(), 'vite.config.ts');
const config = await loadConfigFromFile(
{ command: 'build', mode: 'production' },
configFile
);
const resolved = mergeConfig(config?.config ?? {}, {
build: {
outDir,
copyPublicDir: false,
lib: {
entry,
name: 'ProcessData',
fileName: 'data',
formats: ['iife']
},
minify: !DEBUG_REPLAY
}
} satisfies UserConfig);
return build({
...resolved,
configFile: false
});
}
const enum ProgressStatus {
Success,
Fail,
Working,
Warn
}
/**
* 输出构建步骤
* @param step 步骤数
* @param final 最后一步的状态
*/
function logProgress(step: number, final: ProgressStatus) {
const list = [
`1. 构建前准备`,
`2. 构建客户端代码`,
`3. 构建数据端代码`,
`4. 压缩 main.js`,
`5. 压缩字体`,
`6. 资源分块`,
`7. 最后处理`,
`8. 压缩为 zip`
];
const str = list.reduce((prev, curr, idx) => {
if (idx > step) {
return prev;
} else if (idx === step) {
switch (final) {
case ProgressStatus.Fail:
return prev + `❌ ${curr}\r\n错误信息:\r\n`;
case ProgressStatus.Success:
return prev + `✅ ${curr}\r\n`;
case ProgressStatus.Working:
return prev + `🔄 ${curr}\r\n`;
case ProgressStatus.Warn:
return prev + `⚠️ ${curr}\r\n`;
}
} else {
return prev + `✅ ${curr}\r\n`;
}
}, '');
process.stdout.write(ansi.clear);
process.stdout.write(str);
}
/**
* 获取规范化文件名
* @param output Rollup 输出
* @param client 是否是客户端代码
*/
function getFileName(output: OutputChunk | OutputAsset, client: boolean) {
const name = output.fileName;
if (name.startsWith('index-legacy') && client) {
return 'main';
}
if (name.startsWith('data') && !client) {
return 'main';
}
if (name.startsWith('index.html') && client) {
return 'index';
}
const index = name.indexOf('-legacy');
const unhash = name.slice(0, index);
return unhash;
}
/**
* 获取文件大小
* @param output Rollup 输出
*/
function getFileSize(output: OutputChunk | OutputAsset) {
if (output.type === 'asset') {
if (typeof output.source === 'string') {
return Buffer.byteLength(output.source);
} else {
return output.source.byteLength;
}
} else {
return Buffer.byteLength(output.code);
}
}
const enum ClientDataLevel {
Error,
Suspect,
Pass
}
/**
* 检查客户端与数据端文件大小,并给出数据端引用客户端的可能性
* @param client 客户端文件大小
* @param data 数据端文件大小
*/
function checkClientData(
client: Map<string, number>,
data: Map<string, number>
) {
let error = false;
let warn = false;
const clientMain = client.get('main');
const dataMain = data.get('main');
if (clientMain && dataMain) {
if (clientMain <= dataMain) {
error = true;
} else if (clientMain / 2 < dataMain) {
warn = true;
}
}
let clientTotal = 0;
let dataTotal = 0;
client.forEach(v => {
clientTotal += v;
});
data.forEach(v => {
dataTotal += v;
});
if (clientTotal <= dataTotal) {
error = true;
}
if (clientTotal / 4 < dataTotal) {
warn = true;
}
if (error) return ClientDataLevel.Error;
else if (warn) return ClientDataLevel.Suspect;
else return ClientDataLevel.Pass;
}
async function getAllChars(client: RollupOutput[]) {
const chars = new Set<string>();
// 1. 客户端构建结果
client.forEach(v => {
v.output.forEach(v => {
if (v.type === 'chunk' && v.fileName.startsWith('index')) {
const set = new Set(v.code);
set.forEach(v => chars.add(v));
}
});
});
// 2. 样板内容
const files: string[] = [];
files.push(resolve(tempDir, 'client/main.js'));
files.push(resolve(tempDir, 'client/project/data.js'));
files.push(resolve(tempDir, 'client/project/enemys.js'));
files.push(resolve(tempDir, 'client/project/events.js'));
files.push(resolve(tempDir, 'client/project/functions.js'));
files.push(resolve(tempDir, 'client/project/icons.js'));
files.push(resolve(tempDir, 'client/project/items.js'));
files.push(resolve(tempDir, 'client/project/maps.js'));
const floors = await readdir(resolve(tempDir, 'client/project/floors'));
const ids = floors.map(v => resolve(tempDir, 'client/project/floors', v));
files.push(...ids);
const libs = await readdir(resolve(tempDir, 'client/libs'));
files.push(...libs.map(v => resolve(tempDir, 'client/libs', v)));
await Promise.all(
files.map(async v => {
const stats = await stat(v);
if (!stats.isFile()) return;
const file = await readFile(v, 'utf-8');
const set = new Set(file);
set.forEach(v => chars.add(v));
})
);
return chars;
}
interface CompressedLoadListItem {
type: ResourceType;
name: string;
usage: string;
}
type CompressedLoadList = Record<string, CompressedLoadListItem[]>;
/**
* 生成资源地图 json 文件
*/
function generateResourceJSON(resources: SplittedResource[]) {
const list: CompressedLoadList = {};
resources.forEach(file => {
const uri = `project/resource/${file.fileName}`;
file.content.forEach(content => {
const item: CompressedLoadListItem = {
type: content.type,
name: content.name,
usage: content.usage
};
list[uri] ??= [];
list[uri].push(item);
});
});
return JSON.stringify(list);
}
async function buildGame() {
logProgress(0, ProgressStatus.Working);
//#region 准备步骤
try {
await ensureDir(distDir);
await ensureDir(tempDir);
await emptyDir(distDir);
await emptyDir(tempDir);
await ensureDir(resolve(tempDir, 'fonts'));
await ensureDir(resolve(tempDir, 'common'));
await ensureDir(resolve(tempDir, 'resource'));
} catch (e) {
logProgress(0, ProgressStatus.Fail);
process.stderr.write(String(e));
process.exit(1);
}
logProgress(1, ProgressStatus.Working);
//#region 构建客户端
const clientPack = await buildClient(resolve(tempDir, 'client')).catch(
reason => {
logProgress(1, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
}
);
logProgress(2, ProgressStatus.Working);
//#region 构建数据端
const dataPack = await buildData(
resolve(tempDir, 'data'),
resolve(process.cwd(), 'src/data.ts')
).catch(reason => {
logProgress(2, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
const clientSize = new Map<string, number>();
const dataSize = new Map<string, number>();
const clientPackArr = [];
const dataPackArr = [];
// 判断客户端与数据端的构建包大小,从而推断是否出现了数据端引用客户端的问题
if (clientPack instanceof Array) {
clientPackArr.push(...clientPack);
} else if ('close' in clientPack) {
// pass.
} else {
clientPackArr.push(clientPack);
}
if (dataPack instanceof Array) {
dataPackArr.push(...dataPack);
} else if ('close' in dataPack) {
// pass.
} else {
dataPackArr.push(dataPack);
}
// 获取每个 chunk 的大小
clientPackArr.forEach(v => {
v.output.forEach(v => {
const name = getFileName(v, true);
const size = getFileSize(v);
clientSize.set(name, size);
});
});
dataPackArr.forEach(v => {
v.output.forEach(v => {
const name = getFileName(v, false);
const size = getFileSize(v);
dataSize.set(name, size);
});
});
const level = checkClientData(clientSize, dataSize);
if (level === ClientDataLevel.Error) {
logProgress(2, ProgressStatus.Fail);
process.stderr.write(
`数据端似乎引用了客户端内容,请仔细检查后再构建!`
);
process.exit(1);
}
// 解析全塔属性
const dataFile = await readFile(
resolve(process.cwd(), 'public/project/data.js'),
'utf-8'
);
const dataObject: RequiredData = JSON.parse(
dataFile.split('\n').slice(1).join('\n')
);
const mainData = dataObject.main;
logProgress(3, ProgressStatus.Working);
//#region 压缩 main
try {
const main = await readFile(
resolve(tempDir, 'client/main.js'),
'utf-8'
);
const [head, tail] = main.split('// >>>> body end');
const transformed = await transformAsync(tail, {
presets: [['@babel/preset-env']],
sourceType: 'script',
minified: true,
comments: false
});
if (!transformed || !transformed.code) {
throw new ReferenceError(
`Cannot write main.js since transform result is empty.`
);
}
const code = transformed.code;
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}.ttf`);
const dest = resolve(tempDir, 'fonts');
const plugin = Fontmin.glyph({
text: [...chars].join('')
});
fontmin.src(src).dest(dest).use(plugin);
return fontmin.runAsync();
})
).catch(reason => {
logProgress(4, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
logProgress(5, ProgressStatus.Working);
//#region 资源分块
const iconsFile = await readFile(
resolve(process.cwd(), 'public/project/icons.js'),
'utf-8'
);
const iconsObject: RequiredIconsData = JSON.parse(
iconsFile.split('\n').slice(1).join('\n')
);
const resources = await splitResource(
dataObject,
iconsObject,
resolve(tempDir, 'client'),
resolve(tempDir, 'fonts'),
RESOUCE_SIZE
).catch(reason => {
logProgress(5, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
await Promise.all(
resources.map(v =>
writeFile(resolve(tempDir, 'resource', v.fileName), v.buffer)
)
).catch(reason => {
logProgress(5, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
logProgress(6, ProgressStatus.Working);
//#region 最后处理
const toCopy = [
'libs',
'_server',
'extensions',
'index.html',
'editor.html',
'styles.css',
'logo.png',
'project/floors',
'project/data.js',
'project/enemys.js',
'project/events.js',
'project/functions.js',
'project/icons.js',
'project/items.js',
'project/maps.js',
'project/plugins.js',
'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');
scripts.directory('packages/', resolve(process.cwd(), 'packages'));
scripts.directory(
'packages-user/',
resolve(process.cwd(), 'packages-user')
);
scripts.directory('src/', resolve(process.cwd(), 'src'));
const output = createWriteStream(resolve(distDir, 'source-code.zip'));
scripts.pipe(output);
output.on('error', err => {
throw err;
});
await new Promise<void>(res => {
output.on('finish', () => res());
scripts.finalize();
});
const json = generateResourceJSON(resources);
await writeFile(resolve(distDir, 'loadList.json'), json, 'utf-8');
await copy(
resolve(process.cwd(), 'LICENSE'),
resolve(distDir, 'LICENSE')
);
await copy(
resolve(process.cwd(), 'script/template/启动服务.exe'),
resolve(distDir, '启动服务.exe')
);
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')
);
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(`游戏已经输出至 dist 文件夹\r\n`);
process.stdout.write(`游戏已压缩至 dist.zip 压缩文件\r\n`);
}
// Execute
(() => {
buildGame();
})();
+60
View File
@@ -0,0 +1,60 @@
import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite';
import path from 'path';
import fs from 'fs-extra';
import dts from 'vite-plugin-dts';
const packages = path.resolve(process.cwd(), './packages');
// 获取所有包目录
const packageDirs = ['render'];
// 构建每一个包
async function buildPackages() {
for (const packageName of packageDirs) {
const packageDir = path.join(packages, packageName);
const output = path.join(packageDir, 'dist');
await fs.emptyDir(output);
const configFile = path.resolve('./vite.config.ts');
const config = await loadConfigFromFile(
{ command: 'build', mode: 'production' },
configFile
);
const resolved = mergeConfig(config?.config ?? {}, {
plugins: [
dts({
entryRoot: path.join(packageDir, 'src/index.ts'),
rollupTypes: true,
strictOutput: false,
copyDtsFiles: false,
exclude: ['packages-user/**/*']
})
],
build: {
lib: {
entry: path.join(packageDir, 'src/index.ts'),
name: packageName,
formats: ['es', 'cjs'],
fileName: format => `${packageName}.${format}.js`
},
outDir: output,
sourcemap: true,
emptyOutDir: true
},
publicDir: false
} satisfies UserConfig);
await build(resolved);
console.log(`✅ Package ${packageName} built successfully.`);
}
const dirs = await fs.readdir(packages);
for (const name of dirs) {
const dir = path.join(process.cwd(), name);
await fs.emptyDir(dir);
await fs.rmdir(dir);
}
}
buildPackages().catch(e => {
console.error(e);
process.exit(1);
});
+52
View File
@@ -0,0 +1,52 @@
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);
});
+188
View File
@@ -0,0 +1,188 @@
import JSZip from 'jszip';
import {
RequiredData,
RequiredIconsData,
ResourceType,
ResourceUsage
} from './types';
import { Stats } from 'fs';
import { readdir, readFile, stat } from 'fs/promises';
import { resolve } from 'path';
import { fileHash } from './utils';
export interface ResourceInfo {
name: string;
type: ResourceType;
usage: ResourceUsage;
stats: Stats;
}
export interface SplittedResource {
readonly byteLength: number;
readonly resource: JSZip;
readonly buffer: Uint8Array;
readonly fileName: string;
readonly content: Readonly<ResourceInfo>[];
}
interface ResourceContent extends ResourceInfo {
content: string | Buffer | Uint8Array;
exceed: boolean;
}
interface ResourcePath {
name: string;
path: string;
usage: ResourceUsage;
}
function getTypeByUsage(usage: ResourceUsage): ResourceType {
switch (usage) {
case 'animate':
return 'text';
case 'autotile':
case 'image':
case 'tileset':
return 'image';
case 'sound':
return 'byte';
case 'font':
return 'buffer';
case 'material':
return 'material';
}
}
function readFileOfType(path: string, type: ResourceType) {
if (type === 'text') {
return readFile(path, 'utf-8');
} else {
return readFile(path);
}
}
async function compressFiles(files: ResourceContent[]) {
const zip = new JSZip();
files.forEach(v => {
const dir = `${v.type}/${v.name}`;
zip.file(dir, v.content);
});
const buffer = await zip.generateAsync({ type: 'uint8array' });
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
) {
const result: SplittedResource[] = [];
// 获取所有需要分块的资源
const { animates, fonts, images, sounds, tilesets } = data.main;
const autotiles = Object.keys(icons.autotile);
const materials = await readdir(resolve(base, 'project/materials'));
const paths: ResourcePath[] = [
...animates.map<ResourcePath>(v => ({
name: `${v}.animate`,
path: resolve(base, 'project/animates', `${v}.animate`),
usage: 'animate'
})),
...fonts.map<ResourcePath>(v => ({
name: `${v}.ttf`,
path: resolve(fontsDir, `${v}.ttf`),
usage: 'font'
})),
...images.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/images', v),
usage: 'image'
})),
...sounds.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/sounds', v),
usage: 'sound'
})),
...tilesets.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/tilesets', v),
usage: 'tileset'
})),
...autotiles.map<ResourcePath>(v => ({
name: `${v}.png`,
path: resolve(base, 'project/autotiles', `${v}.png`),
usage: 'autotile'
})),
...materials.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/materials', v),
usage: 'material'
}))
];
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 = {
type,
name,
usage,
stats,
content,
exceed: stats.size > limit
};
return info;
})
);
// 从小到大排序,这样的话可以尽量减小资源分块文件数量
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));
}
return result;
}
+85
View File
@@ -0,0 +1,85 @@
import fs from 'fs/promises';
(async function () {
// 1. 声明道具
const item = await fs.readFile('./public/project/items.js', 'utf-8');
const itemData = JSON.parse(item.split('\n').slice(1).join('')) as any;
let itemDec = 'interface ItemDeclaration {\n';
for (const id in itemData) {
itemDec += ` ${id}: '${itemData[id].cls}';\n`;
}
itemDec += '}';
// 2. 声明公共事件
const event = await fs.readFile('./public/project/events.js', 'utf-8');
const eventData = JSON.parse(event.split('\n').slice(1).join('')) as any;
let eventDec = 'type EventDeclaration = \n';
for (const id in eventData.commonEvent) {
eventDec += ` | '${id}'\n`;
}
// 3. 声明数字id映射与cls映射
const map = await fs.readFile('./public/project/maps.js', 'utf-8');
const mapData = JSON.parse(map.split('\n').slice(1).join('')) as any;
let id2num = 'interface IdToNumber {\n';
let num2id = 'interface NumberToId {\n';
let id2cls = 'interface IdToCls {\n';
for (const num in mapData) {
const { id, cls } = mapData[num];
id2num += ` ${id}: ${num};\n`;
num2id += ` ${num}: '${id}';\n`;
id2cls += ` ${id}: '${cls}';\n`;
}
id2cls += '}';
id2num += '}';
num2id += '}';
// 4. 声明楼层id,图片,动画,音效,文件别名,背景音乐,字体
const data = await fs.readFile('./public/project/data.js', 'utf-8');
const d = (JSON.parse(data.split('\n').slice(1).join('')) as any).main;
let floorId = 'type FloorIds =\n';
let imgs = 'type ImageIds =\n';
let anis = 'type AnimationIds =\n';
let sounds = 'type SoundIds =\n';
let names = 'interface NameMap {\n';
let bgms = 'type BgmIds =\n';
let fonts = 'type FontIds =\n';
floorId += d.floorIds.map((v: string) => ` | '${v}'\n`).join('');
imgs += d.images.map((v: string) => ` | '${v}'\n`).join('');
anis += d.animates.map((v: string) => ` | '${v}'\n`).join('');
sounds += d.sounds.map((v: string) => ` | '${v}'\n`).join('');
bgms += d.bgms.map((v: string) => ` | '${v}'\n`).join('');
fonts += d.fonts.map((v: string) => ` | '${v}'\n`).join('');
for (const name in d.nameMap) {
names += ` '${name}': '${d.nameMap[name]}';\n`;
}
names += '}';
// 5. 写入文件
await fs.writeFile('./src/types/source/cls.d.ts', id2cls, 'utf-8');
await fs.writeFile('./src/types/source/events.d.ts', eventDec, 'utf-8');
await fs.writeFile('./src/types/source/items.d.ts', itemDec, 'utf-8');
await fs.writeFile(
'./src/types/source/maps.d.ts',
`${id2num}\n${num2id}`,
'utf-8'
);
await fs.writeFile(
'./src/types/source/data.d.ts',
`
${floorId}
${d.images.length > 0 ? imgs : 'type ImageIds = never\n'}
${d.animates.length > 0 ? anis : 'type AnimationIds = never\n'}
${d.sounds.length > 0 ? sounds : 'type SoundIds = never\n'}
${d.bgms.length > 0 ? bgms : 'type BgmIds = never\n'}
${d.fonts.length > 0 ? fonts : 'type FontIds = never\n'}
${names}
`,
'utf-8'
);
})();
+772
View File
@@ -0,0 +1,772 @@
/* eslint-disable no-console */
import {
createServer,
loadConfigFromFile,
mergeConfig,
UserConfig
} from 'vite';
import { Server } from 'http';
import { ensureDir, move, pathExists, remove } from 'fs-extra';
import { readFile, readdir, writeFile } from 'fs/promises';
import { resolve, basename, join } from 'path';
import * as rollup from 'rollup';
import typescript from '@rollup/plugin-typescript';
import nodeResolve from '@rollup/plugin-node-resolve';
import EventEmitter from 'events';
import { WebSocket, WebSocketServer } from 'ws';
import chokidar from 'chokidar';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import replace from '@rollup/plugin-replace';
import express, { Request, Response } from 'express';
const [, , vitePortStr = '5173', serverPortStr = '3000'] = process.argv;
const vitePort = parseInt(vitePortStr);
const serverPort = parseInt(serverPortStr);
const checkBase = resolve(process.cwd());
const base = resolve(process.cwd(), 'public');
const enum APIStatus {
Success,
PermissionDeny,
WriteError,
FileNotExist,
ReadError
}
interface ResolveResult {
safe: boolean;
resolved: string;
}
interface RollupInfo {
dir: string;
file: string;
bundled: RefValue<boolean>;
watcher: rollup.RollupWatcher;
}
const rollupMap = new Map<string, RollupInfo>();
let bundleIndex = 0;
let ws: WebSocket;
let wt: chokidar.FSWatcher;
class RefValue<T> extends EventEmitter {
private _value: T;
public get value(): T {
return this._value;
}
public set value(v: T) {
this._value = v;
this.emit('valueChange', v);
}
constructor(value: T) {
super();
this._value = value;
}
waitValueTo(value: T) {
return new Promise(res => {
if (this._value === value) {
res(value);
return;
}
const fn = (v: T) => {
if (v === value) {
this.off('valueChange', fn);
res(v);
}
};
this.on('valueChange', fn);
});
}
}
function resolvePath(path: string): ResolveResult {
const targetPath = resolve(base, path);
const safe = targetPath.startsWith(checkBase);
return {
safe,
resolved: targetPath
};
}
function parseBodyParam(body: string) {
const arr = body.split('&');
const obj: Record<string, string> = {};
arr.forEach(v => {
const [name, value] = v.split('=');
obj[name] = value;
});
return obj;
}
function withSafeCheck(
exec: (req: Request, res: Response, path: ResolveResult) => void
) {
return async (req: Request, res: Response) => {
const query = parseBodyParam(req.body);
const path = query.name ?? '';
if (typeof path !== 'string') {
res.status(500).end('Parameter Error: File path is required.');
return;
}
const dir = resolvePath(path);
if (!dir.safe) {
res.status(500).end(
'Permission Error: Cannot access file outside current working directory.'
);
return;
} else {
exec(req, res, dir);
}
};
}
interface AllFilesStatus {
status: APIStatus;
content: string;
}
function getAllFiles(suffix: string, dir: string, join: string) {
return async (req: Request, res: Response) => {
const query = req.query ? req.query : parseBodyParam(req.body);
const id = query.id;
if (typeof id !== 'string') {
res.status(404).end('Parameter Error: file names is required.');
return;
}
const list = id.split(',');
const tasks = list.map<Promise<AllFilesStatus>>(async v => {
const path = resolvePath(`${dir}${v}${suffix}`);
if (!path.safe) {
return Promise.resolve<AllFilesStatus>({
status: APIStatus.PermissionDeny,
content: ''
});
}
const exist = await pathExists(path.resolved);
if (!exist) {
return Promise.resolve<AllFilesStatus>({
status: APIStatus.FileNotExist,
content: ''
});
}
return readFile(path.resolved, 'utf-8').then(
value => {
return {
status: APIStatus.Success,
content: value
};
},
reason => {
console.error(reason);
return { status: APIStatus.ReadError, content: '' };
}
);
});
const contents = await Promise.all(tasks);
if (contents.every(v => v.status === APIStatus.Success)) {
const content = contents.map(v => v.content).join(join);
if (suffix === '.js') {
res.writeHead(200, { 'Content-type': 'text/javascript' });
}
res.end(content);
} else {
const strArray = contents.map((v, i) => {
if (v.status === APIStatus.PermissionDeny) {
return `Index: ${i}; Permission Error: Cannot access file outside current working directory`;
} else if (v.status === APIStatus.Success) {
return `Index: ${i}: Internal Error: Read file error.`;
} else {
return 'Success';
}
});
const str = strArray.filter(v => v !== 'Success').join('\n');
res.status(500).end(str);
}
};
}
async function getEsmFile(
req: Request,
res: Response,
dir: string
): Promise<void> {
const path = resolvePath(dir);
if (!path.safe) {
res.status(500).end(
'Permission Error: Cannot access file outside current working directory'
);
return;
}
const watcher = rollupMap.get(path.resolved);
if (!watcher) {
const file = (bundleIndex++).toString();
await ensureDir('_bundle');
// 配置rollup监听器
const w = rollup.watch({
input: path.resolved,
output: {
file: `_bundle/${file}.js`,
sourcemap: true,
format: 'es'
},
cache: true,
watch: {
exclude: '**/node_modules/**'
},
plugins: [
typescript({
sourceMap: true,
noCheck: true,
paths: {
'@motajs/*': ['packages/*/src'],
'@user/*': ['packages-user/*/src']
}
}),
nodeResolve({
browser: true,
preferBuiltins: false
}),
commonjs(),
json(),
replace({
'import.meta.env.DEV': 'false'
})
],
onwarn() {}
});
const info: RollupInfo = {
watcher: w,
file: `_bundle/${file}.js`,
dir,
bundled: new RefValue(false)
};
w.on('event', e => {
if (e.code === 'ERROR') {
res.status(500).end('Internal Error: Esm build error.');
console.log(e.error);
}
if (e.code === 'BUNDLE_END') {
info.bundled.value = true;
console.log(`${path.resolved} bundle end`);
}
if (e.code === 'BUNDLE_START') {
info.bundled.value = false;
}
});
w.on('change', id => {
console.log(`${id} changed. Refresh Page.`);
if (ws) {
ws.send(JSON.stringify({ type: 'reload' }));
}
});
rollupMap.set(path.resolved, info);
// 配置完毕,直接重新获取即可(
return getEsmFile(req, res, dir);
} else {
try {
await watcher.bundled.waitValueTo(true);
const content = await readFile(watcher.file, 'utf-8');
res.writeHead(200, { 'Content-type': 'text/javascript' });
res.end(content);
} catch (e) {
console.error(e);
}
}
}
const apiListFile = withSafeCheck(async (_, res, path) => {
const exist = await pathExists(path.resolved);
if (!exist) {
res.status(404).end('Permission Error: Path does not exist.');
return;
}
try {
const data = await readdir(path.resolved);
res.end(JSON.stringify(data));
} catch (e) {
console.error(e);
res.status(500).end('Internal Error: Read dir error.');
}
});
const apiMakeDir = withSafeCheck(async (_, res, path) => {
try {
await ensureDir(path.resolved);
res.end();
} catch (e) {
console.error(e);
res.status(500).end('Internal Error: Make dir error.');
}
});
const apiReadFile = withSafeCheck(async (req, res, path) => {
const query = parseBodyParam(req.body);
const type = query.type ?? 'utf8';
if (typeof type !== 'string') {
res.status(500).end('Internal Error: Query parsed failed.');
return;
}
const exist = await pathExists(path.resolved);
if (!exist) {
res.status(404).end('Permission Error: Path does not exist.');
return;
}
try {
const file = await readFile(path.resolved, {
encoding: type as BufferEncoding
});
res.end(file);
} catch (e) {
console.error(e);
res.status(500).end('Internal Error: Read file error.');
}
});
const apiWriteFile = withSafeCheck(async (req, res, path) => {
const query = parseBodyParam(req.body);
const type = query.type ?? 'utf8';
if (typeof type !== 'string') {
res.status(500).end('Internal Error: Query parsed failed.');
return;
}
const value = query.value;
if (typeof value !== 'string') {
res.status(500).end('Parameter Error: File content is required.');
return;
}
try {
await writeFile(path.resolved, value, {
encoding: type as BufferEncoding
});
res.end();
if (/project(\/|\\)events\.js/.test(path.resolved)) {
doDeclaration('events', value);
}
if (/project(\/|\\)items\.js/.test(path.resolved)) {
doDeclaration('items', value);
}
if (/project(\/|\\)maps\.js/.test(path.resolved)) {
doDeclaration('maps', value);
}
if (/project(\/|\\)data\.js/.test(path.resolved)) {
doDeclaration('data', value);
}
} catch (e) {
console.error(e);
res.status(500).end(
'Internal Error: Fail to write file or fail to do declaration.'
);
}
});
const apiDeleteFile = withSafeCheck(async (_, res, path) => {
const exist = await pathExists(path.resolved);
if (!exist) {
res.status(404).end('Permission Error: Path does not exist.');
return;
}
try {
await remove(path.resolved);
res.end();
} catch (e) {
console.error(e);
res.status(500).end('Internal Error: Remove file error.');
}
});
const apiMoveFile = async (req: Request, res: Response) => {
const query = parseBodyParam(req.body);
const src = query.src;
const dest = query.dest;
if (typeof src !== 'string' || typeof dest !== 'string') {
res.status(500).end(
'Parameter Error: Source path or destination path is required.'
);
return;
}
const srcPath = resolvePath(src);
const destPath = resolvePath(dest);
if (!srcPath.safe || !destPath.safe) {
res.status(500).end(
'Permission Error: Cannot access file outside current working directory.'
);
}
try {
await move(srcPath.resolved, destPath.resolved);
res.end();
} catch (e) {
console.error(e);
res.status(500).end('Internal Error: Move file error.');
}
};
const apiWriteMultiFiles = async (req: Request, res: Response) => {
const query = parseBodyParam(req.body);
const name = query.name;
const value = query.value;
if (typeof name !== 'string' || typeof value !== 'string') {
res.status(500).end(
'Parameter Error: File names and content is required.'
);
return;
}
const pathList = name.split(';');
const valueList = value.split(';');
if (pathList.length !== valueList.length) {
res.status(500).end(
'Parameter Error: File name and content count must match.'
);
return;
}
const tasks = pathList.map<Promise<APIStatus>>((v, i) => {
const path = resolvePath(v);
if (!path.safe) {
return Promise.resolve<APIStatus>(APIStatus.PermissionDeny);
}
return new Promise<APIStatus>(resolve => {
writeFile(v, valueList[i]).then(
() => {
resolve(APIStatus.Success);
},
reason => {
console.error(reason);
resolve(APIStatus.WriteError);
}
);
});
});
const status = await Promise.all(tasks);
if (status.every(v => v === APIStatus.Success)) {
res.end();
} else {
const strArray = status.map((v, i) => {
if (v === APIStatus.PermissionDeny) {
return `Index: ${i}; Permission Error: Cannot access file outside current working directory`;
} else if (v === APIStatus.Success) {
return `Index: ${i}: Internal Error: Write file error.`;
} else {
return 'Success';
}
});
const str = strArray.filter(v => v !== 'Success').join('\n');
res.status(500).end(str);
}
};
const apiGetAllFloors = getAllFiles('.js', 'project/floors/', '\n');
const apiGetAllAnimates = getAllFiles(
'.animate',
'project/animates/',
'@@@~~~###~~~@@@'
);
const apiGetEsmFiles = async (req: Request, res: Response) => {
const query = req.query ? req.query : parseBodyParam(req.body);
const name = query.name;
if (typeof name !== 'string') {
res.status(500).end('Parameter Error: File name is required.');
return;
}
const path = resolvePath(join('..', name));
if (!path.safe) {
res.status(500).end(
'Permission Error: Cannot access file outside current working directory'
);
return;
}
return getEsmFile(req, res, path.resolved);
};
const apiGetPort = async (_req: Request, res: Response) => {
const port = {
vite: vitePort,
server: serverPort
};
res.end(JSON.stringify(port));
};
/**
* 声明某种类型
* @param {string} type 类型
* @param {string} data 信息
*/
async function doDeclaration(type: string, data: string) {
try {
const buf = Buffer.from(data, 'base64');
data = buf.toString('utf-8');
if (type === 'events') {
// 事件
const eventData = JSON.parse(data.split('\n').slice(1).join(''));
let eventDec = 'type EventDeclaration = \n';
for (const id in eventData.commonEvent) {
eventDec += ` | '${id}'\n`;
}
await writeFile('src/types/source/events.d.ts', eventDec, 'utf-8');
} else if (type === 'items') {
// 道具
const itemData = JSON.parse(data.split('\n').slice(1).join(''));
let itemDec = 'interface ItemDeclaration {\n';
for (const id in itemData) {
itemDec += ` ${id}: '${itemData[id].cls}';\n`;
}
itemDec += '}';
await writeFile('src/types/source/items.d.ts', itemDec, 'utf-8');
} else if (type === 'maps') {
// 映射
const d = JSON.parse(data.split('\n').slice(1).join(''));
let id2num = 'interface IdToNumber {\n';
let num2id = 'interface NumberToId {\n';
let id2cls = 'interface IdToCls {\n';
for (const num in d) {
const { id, cls } = d[num];
id2num += ` ${id}: ${num};\n`;
num2id += ` ${num}: '${id}';\n`;
id2cls += ` ${id}: '${cls}';\n`;
}
id2cls += '}';
id2num += '}';
num2id += '}';
await writeFile('src/types/source/cls.d.ts', id2cls, 'utf-8');
await writeFile(
'src/types/source/maps.d.ts',
`${id2num}\n${num2id}`,
'utf-8'
);
} else if (type === 'data') {
// 全塔属性的注册信息
const d = JSON.parse(data.split('\n').slice(1).join('')).main;
let floorId = 'type FloorIds =\n';
let imgs = 'type ImageIds =\n';
let anis = 'type AnimationIds =\n';
let sounds = 'type SoundIds =\n';
let names = 'interface NameMap {\n';
let bgms = 'type BgmIds =\n';
let fonts = 'type FontIds =\n';
floorId += d.floorIds.map((v: any) => ` | '${v}'\n`).join('');
imgs += d.images.map((v: any) => ` | '${v}'\n`).join('');
anis += d.animates.map((v: any) => ` | '${v}'\n`).join('');
sounds += d.sounds.map((v: any) => ` | '${v}'\n`).join('');
bgms += d.bgms.map((v: any) => ` | '${v}'\n`).join('');
fonts += d.fonts.map((v: any) => ` | '${v}'\n`).join('');
for (const name in d.nameMap) {
names += ` '${name}': '${d.nameMap[name]}';\n`;
}
names += '}';
await writeFile(
'src/types/source/data.d.ts',
`
${floorId}
${d.images.length > 0 ? imgs : 'type ImageIds = never\n'}
${d.animates.length > 0 ? anis : 'type AnimationIds = never\n'}
${d.sounds.length > 0 ? sounds : 'type SoundIds = never\n'}
${d.bgms.length > 0 ? bgms : 'type BgmIds = never\n'}
${d.fonts.length > 0 ? fonts : 'type FontIds = never\n'}
${names}
`,
'utf-8'
);
}
} catch (e) {
console.log(e);
}
}
function watchProject() {
if (wt) return;
const watcher = chokidar.watch('public/', {
persistent: true,
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/thirdparty/**',
'**/_docs/**',
'**/_save/**',
/\.min\./,
/(^|[/\\])\../,
/(^|[/\\])[^a-zA-Z:._0-9/\\]/,
/_.*/
]
});
wt = watcher;
watcher.removeAllListeners();
watcher.on('change', async path => {
if (!ws) return;
// 楼层热重载
if (/project(\/|\\)floors(\/|\\).*\.js$/.test(path)) {
const floor = basename(path).slice(0, -3);
ws.send(JSON.stringify({ type: 'floorHotReload', floor }));
console.log(`Floor hot reload: ${floor}.`);
return;
}
// 脚本编辑热重载
if (/project(\/|\\)functions\.js$/.test(path)) {
ws.send(JSON.stringify({ type: 'functionsHotReload' }));
console.log(`Functions hot reload.`);
return;
}
// 数据热重载
if (/project(\/|\\).*\.js/.test(path)) {
const data = basename(path).slice(0, -3);
ws.send(JSON.stringify({ type: 'dataHotReload', data }));
console.log(`Data hot reload: ${data}.`);
return;
}
// css热重载
if (/.*\.css$/.test(path)) {
ws.send(JSON.stringify({ type: 'cssHotReload', path }));
console.log(`Css hot reload: ${path}.`);
return;
}
// 剩余内容全部reload
ws.send(JSON.stringify({ type: 'reload' }));
});
}
function setupSocket(socket: WebSocket) {
ws = socket;
socket.send(JSON.stringify({ type: 'connected' }));
watchProject();
}
async function startWsServer(http: Server) {
if (ws) return;
const server = new WebSocketServer({
server: http
});
server.on('connection', socket => {
setupSocket(socket);
});
}
async function ensureConfig() {
const { resolved, safe } = resolvePath('_server/config.json');
if (!safe) {
throw new Error('Internal Error: Fail to access editor config file.');
}
const exist = await pathExists(resolved);
if (!exist) {
return writeFile(resolved, '{}', { encoding: 'utf-8' });
}
}
(async function () {
// 1. 加载 vite.config.ts
const fsHost = `http://127.0.0.1:${serverPort}`;
const config = await loadConfigFromFile({
command: 'serve',
mode: 'development'
});
if (!config) {
console.error(`Cannot load config file.`);
return;
}
const merged = mergeConfig(config.config, {
server: {
proxy: {
'/readFile': fsHost,
'/writeFile': fsHost,
'/writeMultiFiles': fsHost,
'/listFile': fsHost,
'/makeDir': fsHost,
'/moveFile': fsHost,
'/deleteFile': fsHost,
'/getPort': fsHost,
'^/all/.*': fsHost,
'^/forceTem/.*': {
target: fsHost,
changeOrigin: true,
rewrite(path) {
return path.replace(/^\/forceTem/, '');
}
},
'/danmaku': 'https://h5mota.com/backend/tower/barrage.php'
}
}
} satisfies UserConfig);
// 2. 启动vite服务
const vite = await createServer({
...merged,
configFile: false
});
await vite.listen(vitePort);
console.log(`游戏地址:http://localhost:${vitePort}/`);
// 3. 启动样板http服务
await ensureConfig();
const app = express();
app.use(express.text());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(base));
app.post('/listFile', apiListFile);
app.post('/makeDir', apiMakeDir);
app.post('/readFile', apiReadFile);
app.post('/writeFile', apiWriteFile);
app.post('/deleteFile', apiDeleteFile);
app.post('/moveFile', apiMoveFile);
app.post('/writeMultiFiles', apiWriteMultiFiles);
app.get('/all/__all_floors__.js', apiGetAllFloors);
app.get('/all/__all_animates__', apiGetAllAnimates);
app.get('/esm', apiGetEsmFiles);
app.get('/getPort', apiGetPort);
const server = app.listen(serverPort);
server.on('listening', () => {
console.log(`编辑器地址:http://127.0.0.1:${serverPort}/editor.html`);
console.log(
`文档地址:http://127.0.0.1:${serverPort}/_docs/index.html`
);
});
// 4. 启动样板ws热重载服务
startWsServer(server);
process.on('SIGTERM', () => {
vite.close();
server.close();
process.exit(0);
});
})();
+76
View File
@@ -0,0 +1,76 @@
import fs from 'fs-extra';
import { extname, resolve } from 'path';
import { formatSize } from './utils.js';
(async function () {
let dir = process.argv.slice(2);
if (dir.length === 0) dir = ['./src'];
let totalLines = 0;
let totalFiles = 0;
let totalSize = 0;
const list: Record<string, [number, number, number]> = {};
const ignoreDir = [
'node_modules',
'floors',
'thirdparty',
'dist',
'_bundle'
];
const ignoreFile = ['.d.ts', '.min.'];
const exts = [
'.ts',
'.tsx',
'.js',
'.jsx',
'.vue',
'.less',
'.css',
'.html'
];
const check = async (dir: string) => {
if (ignoreDir.some(v => dir.includes(v))) return;
const d = await fs.readdir(dir);
for await (const one of d) {
if (ignoreFile.some(v => one.includes(v))) continue;
const stat = await fs.stat(resolve(dir, one));
if (stat.isFile()) {
if (exts.some(v => one.endsWith(v))) {
const file = await fs.readFile(resolve(dir, one), 'utf-8');
const lines = file.split('\n').length;
const ext = extname(one);
list[ext] ??= [0, 0, 0];
list[ext][0]++;
list[ext][1] += lines;
list[ext][2] += stat.size;
totalLines += lines;
totalFiles++;
totalSize += stat.size;
}
} else {
await check(resolve(dir, one));
}
}
};
await Promise.all(dir.map(v => check(v)));
const sorted = Object.entries(list).sort((a, b) => {
return a[1][1] - b[1][1];
});
for (const [ext, [file, lines, size]] of sorted) {
console.log(
`${ext.slice(1).padEnd(7, ' ')}files: ${file
.toString()
.padEnd(6, ' ')}lines: ${lines
.toString()
.padEnd(9, ' ')}size: ${formatSize(size)}`
);
}
console.log(
`\x1b[33mtotal files: ${totalFiles
.toString()
.padEnd(6, ' ')}lines: ${totalLines
.toString()
.padEnd(9, ' ')}size: ${formatSize(totalSize)}\x1b[0m`
);
})();
+55
View File
@@ -0,0 +1,55 @@
/* eslint-disable no-console */
import { copy, emptyDir, ensureDir } from 'fs-extra';
import { writeFile } from 'fs/promises';
import { resolve } from 'path';
const base = resolve(process.cwd());
const template = resolve(base, 'template');
const serve = `在 2.B 样板中,不再使用先前的启动服务,而使用一个单独的软件。
你可以加造塔群 959329661 后,在群文件 - 启动服务 中获取,文件夹中会有一个专门的安装教程。`;
async function packTemplate() {
await ensureDir(template);
await emptyDir(template);
// 复制必要文件
const toCopy = [
'.vscode',
'packages',
'packages-user',
'public',
'script',
'src',
'.gitignore',
'.madgerc',
'.prettierignore',
'.prettierrc',
'eslint.config.js',
'index.html',
'LICENSE',
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'README.md',
'tsconfig.json',
'tsconfig.node.json',
'vite.config.ts'
];
await Promise.all(
toCopy.map(v =>
copy(resolve(base, v), resolve(template, v), {
filter: src => !src.includes('node_modules')
})
)
);
await writeFile(resolve(template, '启动服务呢?.txt'), serve, 'utf-8');
console.log(`样板打包完成`);
}
(() => {
packTemplate();
})();
+39
View File
@@ -0,0 +1,39 @@
export interface RequiredData {
main: {
floorIds: string[];
images: string[];
tilesets: string[];
animates: string[];
bgms: string[];
sounds: string[];
fonts: string[];
};
firstData: {
name: string;
};
}
export interface RequiredIconsData {
autotile: {
[x: string]: number;
};
}
export type ResourceUsage =
| 'image'
| 'tileset'
| 'animate'
| 'sound'
| 'font'
| 'autotile'
| 'material';
export type ResourceType =
| 'text'
| 'buffer'
| 'image'
| 'material'
| 'audio'
| 'json'
| 'zip'
| 'byte';
+24
View File
@@ -0,0 +1,24 @@
import { createHash } from 'crypto';
export function uniqueSymbol() {
return Math.ceil(Math.random() * 0xefffffff + 0x10000000).toString(16);
}
export function fileHash(
content: string | Buffer | Uint8Array,
length: number = 8
) {
return createHash('sha256').update(content).digest('hex').slice(0, length);
}
export function formatSize(size: number) {
if (size < 1 << 10) {
return `${size.toFixed(2)}B`;
} else if (size < 1 << 20) {
return `${(size / (1 << 10)).toFixed(2)}KB`;
} else if (size < 1 << 30) {
return `${(size / (1 << 20)).toFixed(2)}MB`;
} else {
return `${(size / (1 << 30)).toFixed(2)}GB`;
}
}