@htyf-mp/cli 0.2.0-beta.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +35 -13
- package/src/HtyfImportsPlugin.mjs +390 -0
- package/src/babel.config.js +10 -0
- package/src/build.mjs +269 -0
- package/src/clean.mjs +83 -0
- package/src/command-executor.mjs +133 -0
- package/src/config.mjs +52 -0
- package/src/constants.mjs +54 -0
- package/src/debug.mjs +179 -0
- package/src/export_godot.mjs +382 -0
- package/src/file-system.mjs +114 -0
- package/src/index.mjs +206 -683
- package/src/logger.mjs +72 -0
- package/src/project-initializer.mjs +273 -0
- package/src/shared-output.json +1 -2
- package/src/template-processor.mjs +137 -0
- package/src/utils-functions.mjs +76 -0
- package/src/utils.mjs +37 -0
- package/src/validators.mjs +82 -0
- package/src/webpack.config.mjs +68 -38
- package/cli.js +0 -288
- package/src/ListImportsPlugin.mjs +0 -98
package/src/debug.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 调试模块
|
|
3
|
+
* 负责启动小程序真机调试服务器
|
|
4
|
+
*
|
|
5
|
+
* @module debug
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fse from 'fs-extra';
|
|
10
|
+
import superstatic from 'superstatic';
|
|
11
|
+
import portfinder from 'portfinder';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import boxen from 'boxen';
|
|
14
|
+
import gradient from 'gradient-string';
|
|
15
|
+
import { Logger } from './logger.mjs';
|
|
16
|
+
import { ConfigValidator } from './validators.mjs';
|
|
17
|
+
import { mpBuildShell } from './build.mjs';
|
|
18
|
+
import { printQrcode } from './utils-functions.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 小程序真机调试
|
|
22
|
+
*
|
|
23
|
+
* 功能流程:
|
|
24
|
+
* 1. 构建小程序包
|
|
25
|
+
* 2. 验证网络配置
|
|
26
|
+
* 3. 启动本地调试服务器
|
|
27
|
+
* 4. 生成调试二维码
|
|
28
|
+
*
|
|
29
|
+
* @param {object} newAppInfo - 应用信息
|
|
30
|
+
* @param {string} newAppInfo.name - 应用名称
|
|
31
|
+
* @param {string} newAppInfo.appid - 应用ID
|
|
32
|
+
* @param {string} newAppInfo.version - 应用版本
|
|
33
|
+
* @param {string} [newAppInfo.entry] - 入口文件(普通小程序必需,Godot 项目不需要)
|
|
34
|
+
* @param {boolean} [isGodot=false] - 是否为 Godot 项目
|
|
35
|
+
* @throws {Error} 当必需字段缺失或网络配置验证失败时抛出错误
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* await mpDebugShell({
|
|
39
|
+
* name: '我的应用',
|
|
40
|
+
* appid: 'com.example.app',
|
|
41
|
+
* version: '1.0.0',
|
|
42
|
+
* entry: './src/index.js'
|
|
43
|
+
* }, false);
|
|
44
|
+
*/
|
|
45
|
+
export async function mpDebugShell(newAppInfo, isGodot = false) {
|
|
46
|
+
try {
|
|
47
|
+
// ========== 参数验证 ==========
|
|
48
|
+
// Godot 项目不需要 entry 字段,普通小程序需要
|
|
49
|
+
if (!isGodot && !newAppInfo.entry) {
|
|
50
|
+
throw new Error('缺少必需的应用信息: entry');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ========== 构建小程序包 ==========
|
|
54
|
+
Logger.info('开始构建小程序包...');
|
|
55
|
+
const zipPath = await mpBuildShell(newAppInfo, isGodot);
|
|
56
|
+
const assetsPath = path.dirname(zipPath);
|
|
57
|
+
const appJsonPath = path.join(assetsPath, 'app.json');
|
|
58
|
+
|
|
59
|
+
Logger.info(`资源路径: ${assetsPath}`);
|
|
60
|
+
const applocaljson = fse.readJsonSync(appJsonPath);
|
|
61
|
+
|
|
62
|
+
// ========== 网络配置验证 ==========
|
|
63
|
+
const networkValidation = await ConfigValidator.validateNetworkConfig();
|
|
64
|
+
if (!networkValidation.isValid) {
|
|
65
|
+
throw new Error(`网络配置验证失败: ${networkValidation.error}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ========== 启动调试服务器 ==========
|
|
69
|
+
const host = networkValidation.host;
|
|
70
|
+
const port = await portfinder.getPortPromise(); // 自动查找可用端口
|
|
71
|
+
|
|
72
|
+
// 创建静态文件服务器
|
|
73
|
+
const app = superstatic.server({
|
|
74
|
+
port: port,
|
|
75
|
+
address: host,
|
|
76
|
+
cwd: assetsPath // 服务器根目录
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// 启动服务器
|
|
80
|
+
const server = app.listen(function () {
|
|
81
|
+
const address = server.address();
|
|
82
|
+
|
|
83
|
+
// ========== 生成调试配置 ==========
|
|
84
|
+
// 构建调试用的 URL
|
|
85
|
+
const debugerAppUrlConfig = `http://${host}:${address.port}/app.json`;
|
|
86
|
+
const debugerZip = `http://${host}:${address.port}/dist.dgz`;
|
|
87
|
+
|
|
88
|
+
// 构建调试参数
|
|
89
|
+
const args = {
|
|
90
|
+
...applocaljson,
|
|
91
|
+
development: true, // 标记为开发模式
|
|
92
|
+
"name": applocaljson.name,
|
|
93
|
+
"appid": applocaljson.appid,
|
|
94
|
+
"version": applocaljson.version,
|
|
95
|
+
appUrlConfig: debugerAppUrlConfig,
|
|
96
|
+
zipUrl: debugerZip,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// 更新 app.json 文件,写入调试 URL
|
|
100
|
+
fse.writeJSONSync(appJsonPath, {
|
|
101
|
+
...fse.readJSONSync(appJsonPath),
|
|
102
|
+
appUrlConfig: debugerAppUrlConfig,
|
|
103
|
+
zipUrl: debugerZip,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 生成调试二维码 URL
|
|
107
|
+
const qrcodeUrl = `https://share.dagouzhi.com/#/pages/index/index?data=${encodeURIComponent(JSON.stringify(args))}`;
|
|
108
|
+
|
|
109
|
+
// ========== 显示调试信息 ==========
|
|
110
|
+
console.log('\n');
|
|
111
|
+
console.log(boxen(
|
|
112
|
+
gradient.rainbow('小程序真机调试') + '\n\n' +
|
|
113
|
+
chalk.cyan('服务器地址: ') + chalk.white(`${host}:${address.port}`) + '\n' +
|
|
114
|
+
chalk.cyan('应用名称: ') + chalk.white(applocaljson.name) + '\n' +
|
|
115
|
+
chalk.cyan('应用ID: ') + chalk.white(applocaljson.appid) + '\n' +
|
|
116
|
+
chalk.cyan('版本: ') + chalk.white(applocaljson.version),
|
|
117
|
+
{
|
|
118
|
+
padding: 1,
|
|
119
|
+
margin: 1,
|
|
120
|
+
borderStyle: 'round',
|
|
121
|
+
borderColor: 'cyan'
|
|
122
|
+
}
|
|
123
|
+
));
|
|
124
|
+
|
|
125
|
+
// 显示调试配置 JSON
|
|
126
|
+
console.log('\n' + chalk.yellow('调试配置:'));
|
|
127
|
+
console.log(JSON.stringify(args, undefined, 2));
|
|
128
|
+
|
|
129
|
+
// 显示调试链接
|
|
130
|
+
console.log('\n' + chalk.green('调试链接:'));
|
|
131
|
+
console.log(qrcodeUrl);
|
|
132
|
+
|
|
133
|
+
// 显示二维码
|
|
134
|
+
console.log('\n' + chalk.blue('二维码:'));
|
|
135
|
+
printQrcode(qrcodeUrl);
|
|
136
|
+
|
|
137
|
+
Logger.info('调试服务器已启动,按 Ctrl+C 停止');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ========== 优雅关闭处理 ==========
|
|
141
|
+
// 移除全局的信号处理,添加专用的服务器关闭处理
|
|
142
|
+
// 这样可以确保服务器能够正确关闭,避免资源泄漏
|
|
143
|
+
const handleShutdown = () => {
|
|
144
|
+
Logger.info('\n正在关闭调试服务器...');
|
|
145
|
+
let isClosed = false;
|
|
146
|
+
|
|
147
|
+
// 尝试关闭服务器
|
|
148
|
+
server.close(() => {
|
|
149
|
+
if (!isClosed) {
|
|
150
|
+
isClosed = true;
|
|
151
|
+
Logger.success('调试服务器已关闭');
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// 如果服务器在 3 秒内没有关闭,强制退出
|
|
157
|
+
// 防止服务器关闭卡死导致进程无法退出
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
if (!isClosed) {
|
|
160
|
+
isClosed = true;
|
|
161
|
+
Logger.warn('强制退出...');
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
}, 3000);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// 注册信号处理器
|
|
168
|
+
// 移除可能存在的全局处理器,避免冲突
|
|
169
|
+
process.removeAllListeners('SIGINT');
|
|
170
|
+
process.removeAllListeners('SIGTERM');
|
|
171
|
+
process.once('SIGINT', handleShutdown); // Ctrl+C
|
|
172
|
+
process.once('SIGTERM', handleShutdown); // 终止信号
|
|
173
|
+
|
|
174
|
+
} catch (error) {
|
|
175
|
+
Logger.error(`小程序调试失败: ${error.message}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Godot 导出模块
|
|
5
|
+
* 负责处理 Godot 游戏项目的导出和打包
|
|
6
|
+
*
|
|
7
|
+
* @module export_godot
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { accessSync, constants as fsConstants } from 'node:fs';
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import fs from 'fs-extra';
|
|
16
|
+
import AdmZip from 'adm-zip';
|
|
17
|
+
import { execa } from 'execa';
|
|
18
|
+
import inquirer from 'inquirer';
|
|
19
|
+
|
|
20
|
+
// ========== 常量定义 ==========
|
|
21
|
+
|
|
22
|
+
/** Godot 编辑器默认路径(macOS) */
|
|
23
|
+
const DEFAULT_GODOT_BIN = '/Applications/Godot.app/Contents/MacOS/Godot';
|
|
24
|
+
|
|
25
|
+
/** Godot 配置缓存目录 */
|
|
26
|
+
const GODOT_CACHE_DIR = path.join(homedir() || process.cwd(), '.htyf');
|
|
27
|
+
|
|
28
|
+
/** Godot 配置缓存文件路径 */
|
|
29
|
+
const GODOT_CACHE_FILE = path.join(GODOT_CACHE_DIR, 'godot-config.json');
|
|
30
|
+
|
|
31
|
+
/** 支持的平台枚举 */
|
|
32
|
+
const PLATFORMS = {
|
|
33
|
+
IOS: 'ios',
|
|
34
|
+
ANDROID: 'android'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** 平台预设名称映射 */
|
|
38
|
+
const PRESET_NAMES = {
|
|
39
|
+
[PLATFORMS.IOS]: 'iOS',
|
|
40
|
+
[PLATFORMS.ANDROID]: 'Android'
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 提示用户输入或确认 Godot 导出选项
|
|
45
|
+
*
|
|
46
|
+
* 如果所有必需参数都已提供,则直接返回,不进行交互式询问
|
|
47
|
+
* 否则通过命令行交互获取用户输入
|
|
48
|
+
*
|
|
49
|
+
* @param {object} [defaults={}] - 默认选项
|
|
50
|
+
* @param {string} [defaults.targetBaseDir] - 导出目标根目录
|
|
51
|
+
* @param {string} [defaults.projectDir] - Godot 项目目录
|
|
52
|
+
* @param {string} [defaults.appid] - 应用ID(用作导出文件名)
|
|
53
|
+
* @param {string} [defaults.preset] - Godot 导出预设名称
|
|
54
|
+
* @param {string} [defaults.platform] - 导出平台('ios' 或 'android')
|
|
55
|
+
* @returns {Promise<object>} Godot 导出选项对象
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const options = await promptGodotOptions({
|
|
59
|
+
* targetBaseDir: './dist',
|
|
60
|
+
* projectDir: './game',
|
|
61
|
+
* appid: 'com.example.game',
|
|
62
|
+
* preset: 'iOS',
|
|
63
|
+
* platform: 'ios'
|
|
64
|
+
* });
|
|
65
|
+
*/
|
|
66
|
+
export async function promptGodotOptions(defaults = {}) {
|
|
67
|
+
// 如果所有必需的值都已提供,直接返回,不进行询问(用于自动化场景)
|
|
68
|
+
if (
|
|
69
|
+
defaults.targetBaseDir &&
|
|
70
|
+
defaults.projectDir &&
|
|
71
|
+
defaults.appid &&
|
|
72
|
+
defaults.preset &&
|
|
73
|
+
defaults.platform
|
|
74
|
+
) {
|
|
75
|
+
return {
|
|
76
|
+
targetBaseDir: String(defaults.targetBaseDir).trim(),
|
|
77
|
+
projectDir: String(defaults.projectDir).trim(),
|
|
78
|
+
name: String(defaults.appid).trim(),
|
|
79
|
+
preset: String(defaults.preset).trim(),
|
|
80
|
+
platform: defaults.platform
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 通过交互式命令行获取用户输入
|
|
85
|
+
const answers = await inquirer.prompt([
|
|
86
|
+
{
|
|
87
|
+
type: 'input',
|
|
88
|
+
name: 'targetBaseDir',
|
|
89
|
+
message: '请输入导出目标根目录 (项目 dist 目录):',
|
|
90
|
+
default: defaults.targetBaseDir || path.resolve(process.cwd(), './dist'),
|
|
91
|
+
validate: (input) => !!input.trim() || '目标根目录不能为空'
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
type: 'input',
|
|
95
|
+
name: 'projectDir',
|
|
96
|
+
message: '请输入 Godot 项目目录:',
|
|
97
|
+
default: defaults.projectDir || process.cwd(),
|
|
98
|
+
validate: (input) => !!input.trim() || '项目目录不能为空'
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'input',
|
|
102
|
+
name: 'name',
|
|
103
|
+
message: '请输入导出文件名称 (不含扩展名):',
|
|
104
|
+
default: defaults.name || 'GodotExport',
|
|
105
|
+
validate: (input) => !!input.trim() || '导出文件名称不能为空'
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
type: 'input',
|
|
109
|
+
name: 'preset',
|
|
110
|
+
message: '请输入 Godot 导出预设名称:',
|
|
111
|
+
default: defaults.preset || '',
|
|
112
|
+
validate: (input) => !!input.trim() || '导出预设名称不能为空'
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'list',
|
|
116
|
+
name: 'platform',
|
|
117
|
+
message: '请选择导出平台:',
|
|
118
|
+
choices: [
|
|
119
|
+
{ name: 'iOS', value: PLATFORMS.IOS },
|
|
120
|
+
{ name: 'Android', value: PLATFORMS.ANDROID }
|
|
121
|
+
],
|
|
122
|
+
default: defaults.platform || 'ios'
|
|
123
|
+
}
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
targetBaseDir: answers.targetBaseDir.trim(),
|
|
128
|
+
projectDir: answers.projectDir.trim(),
|
|
129
|
+
name: answers.name.trim(),
|
|
130
|
+
preset: answers.preset.trim(),
|
|
131
|
+
platform: answers.platform
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 检查文件是否可执行
|
|
137
|
+
*
|
|
138
|
+
* @param {string} filePath - 文件路径
|
|
139
|
+
* @returns {boolean} 文件是否可执行
|
|
140
|
+
*/
|
|
141
|
+
function isExecutable(filePath) {
|
|
142
|
+
if (!filePath) return false;
|
|
143
|
+
if (!fs.existsSync(filePath)) return false;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
accessSync(filePath, fsConstants.X_OK);
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 读取缓存的 Godot 编辑器路径
|
|
155
|
+
*
|
|
156
|
+
* @returns {string} 缓存的路径,如果不存在则返回空字符串
|
|
157
|
+
*/
|
|
158
|
+
function readCachedGodotPath() {
|
|
159
|
+
try {
|
|
160
|
+
if (!fs.existsSync(GODOT_CACHE_FILE)) {
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
const data = fs.readJsonSync(GODOT_CACHE_FILE);
|
|
164
|
+
return typeof data?.godotPath === 'string' ? data.godotPath : '';
|
|
165
|
+
} catch {
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 保存 Godot 编辑器路径到缓存
|
|
172
|
+
*
|
|
173
|
+
* @param {string} godotPath - Godot 编辑器路径
|
|
174
|
+
*/
|
|
175
|
+
async function writeCachedGodotPath(godotPath) {
|
|
176
|
+
try {
|
|
177
|
+
await fs.ensureDir(GODOT_CACHE_DIR);
|
|
178
|
+
await fs.writeJson(GODOT_CACHE_FILE, { godotPath }, { spaces: 2 });
|
|
179
|
+
} catch {
|
|
180
|
+
// 缓存失败不应该阻塞执行
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 提示用户输入 Godot 编辑器路径
|
|
186
|
+
*
|
|
187
|
+
* @param {string} defaultPath - 默认路径
|
|
188
|
+
* @returns {Promise<string>} 用户输入的路径
|
|
189
|
+
*/
|
|
190
|
+
async function promptForGodotBinaryPath(defaultPath) {
|
|
191
|
+
const { godotPath } = await inquirer.prompt([
|
|
192
|
+
{
|
|
193
|
+
type: 'input',
|
|
194
|
+
name: 'godotPath',
|
|
195
|
+
message: '请输入 Godot Editor 可执行文件路径:',
|
|
196
|
+
default: defaultPath,
|
|
197
|
+
validate: (input) => !!input.trim() || '路径不能为空'
|
|
198
|
+
}
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
return godotPath.trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 解析并获取 Godot 编辑器可执行文件路径
|
|
206
|
+
*
|
|
207
|
+
* 优先级:环境变量 > 缓存 > 默认路径
|
|
208
|
+
* 总是会询问用户,但将缓存值或默认值作为默认输入
|
|
209
|
+
*
|
|
210
|
+
* @returns {Promise<string>} Godot 编辑器可执行文件路径
|
|
211
|
+
* @throws {Error} 当路径不可执行时抛出错误
|
|
212
|
+
*/
|
|
213
|
+
async function resolveGodotBinary() {
|
|
214
|
+
// 获取可能的默认值(优先级:环境变量 > 缓存 > 默认路径)
|
|
215
|
+
const override = process.env.GODOT_EDITOR ? process.env.GODOT_EDITOR.trim() : '';
|
|
216
|
+
const cached = readCachedGodotPath();
|
|
217
|
+
|
|
218
|
+
// 确定默认值
|
|
219
|
+
let defaultPath = override || cached || DEFAULT_GODOT_BIN;
|
|
220
|
+
|
|
221
|
+
// 总是询问用户,但将缓存值或默认值作为默认输入
|
|
222
|
+
const godotPath = await promptForGodotBinaryPath(defaultPath);
|
|
223
|
+
|
|
224
|
+
// 验证路径是否可执行
|
|
225
|
+
if (!isExecutable(godotPath)) {
|
|
226
|
+
throw new Error(`Godot 二进制文件不可执行: ${godotPath}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 保存用户输入作为下次的默认值
|
|
230
|
+
await writeCachedGodotPath(godotPath);
|
|
231
|
+
return godotPath;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 执行 Godot 命令行命令
|
|
236
|
+
*
|
|
237
|
+
* @param {string} godotBin - Godot 编辑器可执行文件路径
|
|
238
|
+
* @param {string[]} args - 命令参数
|
|
239
|
+
*/
|
|
240
|
+
async function runGodotCommand(godotBin, args) {
|
|
241
|
+
await execa(godotBin, args, { stdio: 'inherit' });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 处理 Android 平台导出
|
|
246
|
+
*
|
|
247
|
+
* Android 导出流程:
|
|
248
|
+
* 1. 使用 Godot 导出为 ZIP 包
|
|
249
|
+
* 2. 解压 ZIP 包到指定目录
|
|
250
|
+
* 3. 清理临时 ZIP 文件
|
|
251
|
+
*
|
|
252
|
+
* @param {object} options - 导出选项
|
|
253
|
+
* @param {string} options.godotBin - Godot 编辑器路径
|
|
254
|
+
* @param {string} options.projectDir - Godot 项目目录
|
|
255
|
+
* @param {string} options.preset - 导出预设名称
|
|
256
|
+
* @param {string} options.targetBaseDir - 目标根目录
|
|
257
|
+
* @param {string} options.name - 导出文件名(不含扩展名)
|
|
258
|
+
*/
|
|
259
|
+
async function handleAndroidExport({ godotBin, projectDir, preset, targetBaseDir, name }) {
|
|
260
|
+
// Android 导出路径:targetBaseDir/android/app/src/main/assets/
|
|
261
|
+
const assetsBaseDir = path.join(targetBaseDir, 'android', 'app', 'src', 'main', 'assets');
|
|
262
|
+
await fs.ensureDir(assetsBaseDir);
|
|
263
|
+
|
|
264
|
+
// 使用 Godot 导出为 ZIP 包
|
|
265
|
+
const zipPath = path.join(assetsBaseDir, `${name}.zip`);
|
|
266
|
+
await runGodotCommand(godotBin, ['--headless', '--path', projectDir, '--export-pack', preset, zipPath]);
|
|
267
|
+
|
|
268
|
+
// 解压 ZIP 包到目标目录
|
|
269
|
+
const targetDir = path.join(assetsBaseDir, name);
|
|
270
|
+
await fs.remove(targetDir);
|
|
271
|
+
await fs.ensureDir(targetDir);
|
|
272
|
+
|
|
273
|
+
const zip = new AdmZip(zipPath);
|
|
274
|
+
zip.extractAllTo(targetDir, true);
|
|
275
|
+
|
|
276
|
+
// 清理临时 ZIP 文件
|
|
277
|
+
await fs.remove(zipPath);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 处理 iOS 平台导出
|
|
282
|
+
*
|
|
283
|
+
* iOS 导出流程:
|
|
284
|
+
* 1. 使用 Godot 直接导出为 PCK 文件
|
|
285
|
+
*
|
|
286
|
+
* @param {object} options - 导出选项
|
|
287
|
+
* @param {string} options.godotBin - Godot 编辑器路径
|
|
288
|
+
* @param {string} options.projectDir - Godot 项目目录
|
|
289
|
+
* @param {string} options.preset - 导出预设名称
|
|
290
|
+
* @param {string} options.targetBaseDir - 目标根目录
|
|
291
|
+
* @param {string} options.name - 导出文件名(不含扩展名)
|
|
292
|
+
*/
|
|
293
|
+
async function handleIosExport({ godotBin, projectDir, preset, targetBaseDir, name }) {
|
|
294
|
+
// iOS 导出路径:targetBaseDir/ios/
|
|
295
|
+
const assetsBaseDir = path.join(targetBaseDir, 'ios');
|
|
296
|
+
await fs.ensureDir(assetsBaseDir);
|
|
297
|
+
|
|
298
|
+
// 使用 Godot 直接导出为 PCK 文件
|
|
299
|
+
const outputPath = path.join(assetsBaseDir, `${name}.pck`);
|
|
300
|
+
await runGodotCommand(godotBin, ['--headless', '--path', projectDir, '--export-pack', preset, outputPath]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 导出 Godot 项目
|
|
305
|
+
*
|
|
306
|
+
* 主要流程:
|
|
307
|
+
* 1. 解析并获取 Godot 编辑器路径
|
|
308
|
+
* 2. 执行资源导入(执行两次以确保资源正确导入)
|
|
309
|
+
* 3. 根据平台执行相应的导出流程
|
|
310
|
+
*
|
|
311
|
+
* @param {object} options - 导出选项
|
|
312
|
+
* @param {string} options.platform - 导出平台('ios' 或 'android')
|
|
313
|
+
* @param {string} options.projectDir - Godot 项目目录
|
|
314
|
+
* @param {string} options.preset - Godot 导出预设名称
|
|
315
|
+
* @param {string} options.targetBaseDir - 目标根目录
|
|
316
|
+
* @param {string} options.name - 导出文件名(不含扩展名)
|
|
317
|
+
* @throws {Error} 当平台不支持时抛出错误
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* await exportGodot({
|
|
321
|
+
* platform: 'ios',
|
|
322
|
+
* projectDir: './game',
|
|
323
|
+
* preset: 'iOS',
|
|
324
|
+
* targetBaseDir: './dist',
|
|
325
|
+
* name: 'MyGame'
|
|
326
|
+
* });
|
|
327
|
+
*/
|
|
328
|
+
export async function exportGodot(options) {
|
|
329
|
+
// 解析并获取 Godot 编辑器路径
|
|
330
|
+
const godotBin = await resolveGodotBinary();
|
|
331
|
+
const { platform, projectDir, preset, targetBaseDir, name } = options;
|
|
332
|
+
|
|
333
|
+
// 执行两次 import 以确保资源正确导入
|
|
334
|
+
// 某些情况下,第一次导入可能不完整,执行两次可以确保所有资源都被正确导入
|
|
335
|
+
const importArgs = ['--headless', '--path', projectDir, '--import'];
|
|
336
|
+
await runGodotCommand(godotBin, importArgs);
|
|
337
|
+
await runGodotCommand(godotBin, importArgs);
|
|
338
|
+
|
|
339
|
+
// 根据平台执行相应的导出流程
|
|
340
|
+
switch (platform) {
|
|
341
|
+
case PLATFORMS.IOS:
|
|
342
|
+
await handleIosExport({ godotBin, projectDir, preset, targetBaseDir, name });
|
|
343
|
+
break;
|
|
344
|
+
case PLATFORMS.ANDROID:
|
|
345
|
+
await handleAndroidExport({ godotBin, projectDir, preset, targetBaseDir, name });
|
|
346
|
+
break;
|
|
347
|
+
default:
|
|
348
|
+
throw new Error(`Unsupported platform: ${platform}. Supported: ${Object.values(PLATFORMS).join(', ')}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 主函数(当文件作为 CLI 直接执行时)
|
|
354
|
+
*
|
|
355
|
+
* 如果该文件被直接执行(而非作为模块导入),则运行主函数
|
|
356
|
+
*/
|
|
357
|
+
async function main() {
|
|
358
|
+
const options = await promptGodotOptions();
|
|
359
|
+
await exportGodot(options);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 检查当前文件是否作为 CLI 直接执行
|
|
364
|
+
*
|
|
365
|
+
* @returns {boolean} 是否为 CLI 执行
|
|
366
|
+
*/
|
|
367
|
+
const isCliExecution = (() => {
|
|
368
|
+
if (!process.argv[1]) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
const cliUrl = pathToFileURL(process.argv[1]).href;
|
|
372
|
+
return cliUrl === import.meta.url;
|
|
373
|
+
})();
|
|
374
|
+
|
|
375
|
+
// 如果作为 CLI 直接执行,运行主函数
|
|
376
|
+
if (isCliExecution) {
|
|
377
|
+
main().catch((error) => {
|
|
378
|
+
console.error(error.message || error);
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fse from 'fs-extra';
|
|
4
|
+
import { Logger } from './logger.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 文件系统工具类
|
|
8
|
+
*/
|
|
9
|
+
export class FileSystemUtils {
|
|
10
|
+
static ensureDirectory(dirPath) {
|
|
11
|
+
try {
|
|
12
|
+
fse.ensureDirSync(dirPath);
|
|
13
|
+
Logger.debug(`目录已确保存在: ${dirPath}`);
|
|
14
|
+
return true;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
Logger.error(`创建目录失败: ${dirPath}`, error.message);
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static cleanDirectory(dirPath) {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(dirPath)) {
|
|
24
|
+
fse.removeSync(dirPath);
|
|
25
|
+
Logger.info(`目录已清理: ${dirPath}`);
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
Logger.error(`清理目录失败: ${dirPath}`, error.message);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static backupFile(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(filePath)) {
|
|
37
|
+
const backupPath = `${filePath}.backup.${Date.now()}`;
|
|
38
|
+
fse.copySync(filePath, backupPath);
|
|
39
|
+
Logger.info(`文件已备份: ${backupPath}`);
|
|
40
|
+
return backupPath;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
Logger.error(`备份文件失败: ${filePath}`, error.message);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static getFileSize(filePath) {
|
|
50
|
+
try {
|
|
51
|
+
const stats = fs.statSync(filePath);
|
|
52
|
+
return stats.size;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static formatFileSize(bytes) {
|
|
59
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
60
|
+
if (bytes === 0) return '0 Bytes';
|
|
61
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
62
|
+
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 检查目录是否为空
|
|
67
|
+
* @param {string} dirPath
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
static isDirectoryEmpty(dirPath) {
|
|
71
|
+
if (!fs.existsSync(dirPath)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const entries = fs.readdirSync(dirPath);
|
|
76
|
+
return entries.length === 0;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
Logger.warn(`读取目录内容失败: ${dirPath}`, error.message);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static validatePath(path) {
|
|
84
|
+
// if (/[ - fa5]/gi.test(path)) {
|
|
85
|
+
// return { isValid: false, error: '路径中包含中文字符' };
|
|
86
|
+
// }
|
|
87
|
+
return { isValid: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 计算目录大小
|
|
92
|
+
* @param {string} dirPath - 目录路径
|
|
93
|
+
* @returns {number} 目录大小(字节)
|
|
94
|
+
*/
|
|
95
|
+
static calculateDirectorySize(dirPath) {
|
|
96
|
+
if (!fs.existsSync(dirPath)) return 0;
|
|
97
|
+
let size = 0;
|
|
98
|
+
try {
|
|
99
|
+
const items = fse.readdirSync(dirPath, { withFileTypes: true });
|
|
100
|
+
for (const item of items) {
|
|
101
|
+
const itemPath = path.join(dirPath, item.name);
|
|
102
|
+
if (item.isDirectory()) {
|
|
103
|
+
size += this.calculateDirectorySize(itemPath);
|
|
104
|
+
} else {
|
|
105
|
+
size += fs.statSync(itemPath).size;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
Logger.warn(`计算目录大小失败: ${dirPath}`, error.message);
|
|
110
|
+
}
|
|
111
|
+
return size;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|