@playcraft/cli 0.0.19 → 0.0.21
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/dist/cli-root-help.js +22 -0
- package/dist/commands/audio.js +219 -0
- package/dist/commands/build-all.js +129 -13
- package/dist/commands/build.js +191 -14
- package/dist/commands/image.js +470 -0
- package/dist/commands/tools.js +447 -0
- package/dist/index.js +22 -1
- package/dist/playable/base-builder.js +265 -0
- package/dist/playable/builder.js +1462 -0
- package/dist/playable/converter.js +150 -0
- package/dist/playable/index.js +3 -0
- package/dist/playable/platforms/base.js +12 -0
- package/dist/playable/platforms/facebook.js +37 -0
- package/dist/playable/platforms/index.js +24 -0
- package/dist/playable/platforms/snapchat.js +59 -0
- package/dist/playable/playable-builder.js +521 -0
- package/dist/playable/types.js +1 -0
- package/dist/playable/vite/config-builder.js +136 -0
- package/dist/playable/vite/platform-configs.js +102 -0
- package/dist/playable/vite/plugin-model-compression.js +63 -0
- package/dist/playable/vite/plugin-platform.js +65 -0
- package/dist/playable/vite/plugin-playcanvas.js +454 -0
- package/dist/playable/vite-builder.js +125 -0
- package/dist/utils/agent-api-client.js +82 -0
- package/dist/utils/audio-processor.js +269 -0
- package/package.json +8 -3
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** 与 docs/cli/capabilities.md 一致的三块划分说明(纯文本,供 --help / topics 复用) */
|
|
2
|
+
export function getCliTopicsHelpText() {
|
|
3
|
+
return [
|
|
4
|
+
'命令分区(完整说明见仓库 docs/cli/capabilities.md):',
|
|
5
|
+
'',
|
|
6
|
+
' 本地开发 init, start, stop, status, logs, config, sync, fix-ids',
|
|
7
|
+
' 素材 tools generate-* | image <子命令> | audio <子命令>',
|
|
8
|
+
' 平台 build, build-base, build-playable, build-all, analyze, inspect, upgrade',
|
|
9
|
+
' tools 除 generate-* 外(Git / 沙箱构建 / 发布 / 列表 / Prefab 等,需后端 API)',
|
|
10
|
+
'',
|
|
11
|
+
'也可运行: playcraft topics',
|
|
12
|
+
'',
|
|
13
|
+
].join('\n');
|
|
14
|
+
}
|
|
15
|
+
/** 根程序短描述(单行摘要 + 多行补充) */
|
|
16
|
+
export const CLI_ROOT_DESCRIPTION = [
|
|
17
|
+
'PlayCraft CLI:本地开发 Agent、素材处理、Playable 与各广告渠道打包、云端平台 API。',
|
|
18
|
+
'命令分区见下方;详情见 docs/cli/capabilities.md。',
|
|
19
|
+
].join('\n');
|
|
20
|
+
export function registerRootProgramHelp(program) {
|
|
21
|
+
program.addHelpText('before', () => getCliTopicsHelpText());
|
|
22
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { getAudioInfo, compressAudio, trimAudio, convertAudio, fadeAudio, concatAudio, normalizeAudio, loopAudio, mixAudio, } from '../utils/audio-processor.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse and validate `audio mix --volumes` / `--offsets` against `--inputs`.
|
|
4
|
+
*/
|
|
5
|
+
function buildValidatedMixTracks(inputs, volumeStrs, offsetStrs) {
|
|
6
|
+
const n = inputs.length;
|
|
7
|
+
if (volumeStrs.length > 0 && volumeStrs.length !== n) {
|
|
8
|
+
throw new Error(`--volumes: expected ${n} value(s) to match ${n} --inputs, got ${volumeStrs.length}`);
|
|
9
|
+
}
|
|
10
|
+
if (offsetStrs.length > 0 && offsetStrs.length !== n) {
|
|
11
|
+
throw new Error(`--offsets: expected ${n} value(s) to match ${n} --inputs, got ${offsetStrs.length}`);
|
|
12
|
+
}
|
|
13
|
+
return inputs.map((input, i) => {
|
|
14
|
+
let volume = 1.0;
|
|
15
|
+
let offset = 0;
|
|
16
|
+
const volRaw = volumeStrs[i];
|
|
17
|
+
const offRaw = offsetStrs[i];
|
|
18
|
+
if (volRaw !== undefined) {
|
|
19
|
+
const v = Number.parseFloat(volRaw);
|
|
20
|
+
if (!Number.isFinite(v)) {
|
|
21
|
+
throw new Error(`Invalid --volumes value for track ${i + 1}: "${volRaw}" is not a valid number`);
|
|
22
|
+
}
|
|
23
|
+
if (v < 0 || v > 1) {
|
|
24
|
+
throw new Error(`Invalid --volumes value for track ${i + 1}: ${v} (expected 0.0–1.0)`);
|
|
25
|
+
}
|
|
26
|
+
volume = v;
|
|
27
|
+
}
|
|
28
|
+
if (offRaw !== undefined) {
|
|
29
|
+
const o = Number.parseFloat(offRaw);
|
|
30
|
+
if (!Number.isFinite(o)) {
|
|
31
|
+
throw new Error(`Invalid --offsets value for track ${i + 1}: "${offRaw}" is not a valid number`);
|
|
32
|
+
}
|
|
33
|
+
if (o < 0) {
|
|
34
|
+
throw new Error(`Invalid --offsets value for track ${i + 1}: ${o} (must be >= 0 seconds)`);
|
|
35
|
+
}
|
|
36
|
+
offset = o;
|
|
37
|
+
}
|
|
38
|
+
return { input, volume, offset };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Register all local audio processing commands under `playcraft audio`.
|
|
43
|
+
* These commands run entirely locally using ffmpeg -- no API calls required.
|
|
44
|
+
*/
|
|
45
|
+
export function registerAudioCommands(program) {
|
|
46
|
+
const audio = program
|
|
47
|
+
.command('audio')
|
|
48
|
+
.description('素材工具:本地音频处理(ffmpeg,不调用 API)');
|
|
49
|
+
// ── audio-info ──────────────────────────────────────────────────────────
|
|
50
|
+
audio
|
|
51
|
+
.command('info')
|
|
52
|
+
.description('获取音频文件元信息(时长/体积/格式/比特率/采样率/声道数)')
|
|
53
|
+
.requiredOption('--input <file>', '输入音频文件路径')
|
|
54
|
+
.action(async (opts) => {
|
|
55
|
+
try {
|
|
56
|
+
const info = await getAudioInfo(opts.input);
|
|
57
|
+
console.log(JSON.stringify(info, null, 2));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error('audio info failed:', err.message);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
// ── audio-compress ───────────────────────────────────────────────────────
|
|
65
|
+
audio
|
|
66
|
+
.command('compress')
|
|
67
|
+
.description('压缩音频文件(降低比特率/采样率,转单声道,或指定目标体积)')
|
|
68
|
+
.requiredOption('--input <file>', '输入音频文件路径')
|
|
69
|
+
.requiredOption('--output <file>', '输出音频文件路径')
|
|
70
|
+
.option('--bitrate <kbps>', '目标比特率(kbps),如 64', parseInt)
|
|
71
|
+
.option('--sample-rate <hz>', '目标采样率(Hz),如 22050', parseInt)
|
|
72
|
+
.option('--mono', '转为单声道', false)
|
|
73
|
+
.option('--target-size <size>', '目标体积(自动计算比特率),如 30KB、1.5MB')
|
|
74
|
+
.action(async (opts) => {
|
|
75
|
+
try {
|
|
76
|
+
await compressAudio(opts.input, opts.output, {
|
|
77
|
+
bitrate: opts.bitrate,
|
|
78
|
+
sampleRate: opts.sampleRate,
|
|
79
|
+
mono: opts.mono,
|
|
80
|
+
targetSize: opts.targetSize,
|
|
81
|
+
});
|
|
82
|
+
const info = await getAudioInfo(opts.output);
|
|
83
|
+
console.log(`Compressed: ${opts.output} (${(info.fileSize / 1024).toFixed(1)}KB, ${info.bitrate}kbps)`);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.error('audio compress failed:', err.message);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// ── audio-trim ───────────────────────────────────────────────────────────
|
|
91
|
+
audio
|
|
92
|
+
.command('trim')
|
|
93
|
+
.description('裁剪音频到指定时间范围')
|
|
94
|
+
.requiredOption('--input <file>', '输入音频文件路径')
|
|
95
|
+
.requiredOption('--output <file>', '输出音频文件路径')
|
|
96
|
+
.requiredOption('--start <seconds>', '起始时间(秒)', parseFloat)
|
|
97
|
+
.requiredOption('--end <seconds>', '结束时间(秒)', parseFloat)
|
|
98
|
+
.action(async (opts) => {
|
|
99
|
+
try {
|
|
100
|
+
await trimAudio(opts.input, opts.output, opts.start, opts.end);
|
|
101
|
+
const info = await getAudioInfo(opts.output);
|
|
102
|
+
console.log(`Trimmed: ${opts.output} (${info.duration.toFixed(2)}s, ${(info.fileSize / 1024).toFixed(1)}KB)`);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.error('audio trim failed:', err.message);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// ── audio-convert ────────────────────────────────────────────────────────
|
|
110
|
+
audio
|
|
111
|
+
.command('convert')
|
|
112
|
+
.description('音频格式转换(如 WAV → MP3)')
|
|
113
|
+
.requiredOption('--input <file>', '输入音频文件路径')
|
|
114
|
+
.requiredOption('--output <file>', '输出音频文件路径(扩展名决定格式)')
|
|
115
|
+
.option('--bitrate <kbps>', '目标比特率(kbps)', parseInt)
|
|
116
|
+
.action(async (opts) => {
|
|
117
|
+
try {
|
|
118
|
+
await convertAudio(opts.input, opts.output, opts.bitrate);
|
|
119
|
+
const info = await getAudioInfo(opts.output);
|
|
120
|
+
console.log(`Converted: ${opts.output} (${info.format}, ${(info.fileSize / 1024).toFixed(1)}KB)`);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error('audio convert failed:', err.message);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// ── audio-fade ───────────────────────────────────────────────────────────
|
|
128
|
+
audio
|
|
129
|
+
.command('fade')
|
|
130
|
+
.description('为音频添加淡入/淡出效果')
|
|
131
|
+
.requiredOption('--input <file>', '输入音频文件路径')
|
|
132
|
+
.requiredOption('--output <file>', '输出音频文件路径')
|
|
133
|
+
.option('--fade-in <seconds>', '淡入时长(秒)', parseFloat)
|
|
134
|
+
.option('--fade-out <seconds>', '淡出时长(秒)', parseFloat)
|
|
135
|
+
.action(async (opts) => {
|
|
136
|
+
try {
|
|
137
|
+
await fadeAudio(opts.input, opts.output, {
|
|
138
|
+
fadeIn: opts.fadeIn,
|
|
139
|
+
fadeOut: opts.fadeOut,
|
|
140
|
+
});
|
|
141
|
+
console.log(`Faded: ${opts.output}`);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
console.error('audio fade failed:', err.message);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// ── audio-concat ─────────────────────────────────────────────────────────
|
|
149
|
+
audio
|
|
150
|
+
.command('concat')
|
|
151
|
+
.description('将多个音频文件顺序拼接为一个')
|
|
152
|
+
.requiredOption('--inputs <files...>', '输入音频文件列表')
|
|
153
|
+
.requiredOption('--output <file>', '输出音频文件路径')
|
|
154
|
+
.action(async (opts) => {
|
|
155
|
+
try {
|
|
156
|
+
await concatAudio({ inputs: opts.inputs, output: opts.output });
|
|
157
|
+
const info = await getAudioInfo(opts.output);
|
|
158
|
+
console.log(`Concatenated: ${opts.output} (${info.duration.toFixed(2)}s, ${(info.fileSize / 1024).toFixed(1)}KB)`);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error('audio concat failed:', err.message);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// ── audio-normalize ──────────────────────────────────────────────────────
|
|
166
|
+
audio
|
|
167
|
+
.command('normalize')
|
|
168
|
+
.description('音量归一化(LUFS 响度标准化)')
|
|
169
|
+
.requiredOption('--input <file>', '输入音频文件路径')
|
|
170
|
+
.requiredOption('--output <file>', '输出音频文件路径')
|
|
171
|
+
.option('--target-loudness <lufs>', '目标响度(LUFS),如 -16', parseFloat, -16)
|
|
172
|
+
.action(async (opts) => {
|
|
173
|
+
try {
|
|
174
|
+
await normalizeAudio(opts.input, opts.output, opts.targetLoudness);
|
|
175
|
+
console.log(`Normalized: ${opts.output} (target: ${opts.targetLoudness} LUFS)`);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
console.error('audio normalize failed:', err.message);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
// ── audio-loop ───────────────────────────────────────────────────────────
|
|
183
|
+
audio
|
|
184
|
+
.command('loop')
|
|
185
|
+
.description('对音频进行无缝循环处理(首尾交叉淡化)')
|
|
186
|
+
.requiredOption('--input <file>', '输入音频文件路径')
|
|
187
|
+
.requiredOption('--output <file>', '输出音频文件路径')
|
|
188
|
+
.option('--crossfade <seconds>', '交叉淡化时长(秒),默认 0.5', parseFloat, 0.5)
|
|
189
|
+
.action(async (opts) => {
|
|
190
|
+
try {
|
|
191
|
+
await loopAudio(opts.input, opts.output, opts.crossfade);
|
|
192
|
+
console.log(`Loop processed: ${opts.output} (crossfade=${opts.crossfade}s)`);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error('audio loop failed:', err.message);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// ── audio-mix ────────────────────────────────────────────────────────────
|
|
200
|
+
audio
|
|
201
|
+
.command('mix')
|
|
202
|
+
.description('多轨混音(叠加多个音频,可指定音量和时间偏移)')
|
|
203
|
+
.requiredOption('--inputs <files...>', '输入音频文件列表')
|
|
204
|
+
.requiredOption('--output <file>', '输出音频文件路径')
|
|
205
|
+
.option('--volumes <values...>', '各轨音量(0.0-1.0),数量需与 --inputs 一致,默认全为 1.0', (val, prev) => [...prev, val], [])
|
|
206
|
+
.option('--offsets <values...>', '各轨时间偏移(秒),数量需与 --inputs 一致,默认全为 0', (val, prev) => [...prev, val], [])
|
|
207
|
+
.action(async (opts) => {
|
|
208
|
+
try {
|
|
209
|
+
const tracks = buildValidatedMixTracks(opts.inputs, opts.volumes, opts.offsets);
|
|
210
|
+
await mixAudio(tracks, opts.output);
|
|
211
|
+
const info = await getAudioInfo(opts.output);
|
|
212
|
+
console.log(`Mixed: ${opts.output} (${tracks.length} tracks, ${info.duration.toFixed(2)}s, ${(info.fileSize / 1024).toFixed(1)}KB)`);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
console.error('audio mix failed:', err.message);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -2,7 +2,7 @@ import path from 'path';
|
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { BaseBuilder, ViteBuilder, PlayableBuilder, detectAmmoUsage } from '@playcraft/build';
|
|
5
|
+
import { BaseBuilder, ViteBuilder, PlayableBuilder, EngineDetector, detectAmmoUsage } from '@playcraft/build';
|
|
6
6
|
import { loadBuildConfig } from '../build-config.js';
|
|
7
7
|
import inquirer from 'inquirer';
|
|
8
8
|
/**
|
|
@@ -56,7 +56,7 @@ const platformFormatConfig = {
|
|
|
56
56
|
moloco: { formats: ['html'], default: 'html' },
|
|
57
57
|
adikteev: { formats: ['html'], default: 'html' },
|
|
58
58
|
remerge: { formats: ['html'], default: 'html' },
|
|
59
|
-
google: { formats: ['zip'], default: '
|
|
59
|
+
google: { formats: ['html', 'zip'], default: 'html' },
|
|
60
60
|
tiktok: { formats: ['zip'], default: 'zip' },
|
|
61
61
|
liftoff: { formats: ['zip'], default: 'zip' },
|
|
62
62
|
bigo: { formats: ['zip'], default: 'zip' },
|
|
@@ -146,7 +146,7 @@ export async function buildAllCommand(projectPath, options) {
|
|
|
146
146
|
let targetPlatforms;
|
|
147
147
|
if (options.platforms) {
|
|
148
148
|
// 命令行指定了平台列表
|
|
149
|
-
targetPlatforms = options.platforms.split(',').map(p => p.trim()).filter(p => ALL_PLATFORMS.includes(p));
|
|
149
|
+
targetPlatforms = options.platforms.split(',').map(p => p.trim()).filter((p) => ALL_PLATFORMS.includes(p));
|
|
150
150
|
if (targetPlatforms.length === 0) {
|
|
151
151
|
throw new Error(`无效的平台列表: ${options.platforms}\n可用平台: ${ALL_PLATFORMS.join(', ')}`);
|
|
152
152
|
}
|
|
@@ -167,7 +167,7 @@ export async function buildAllCommand(projectPath, options) {
|
|
|
167
167
|
{ name: '🌐 全部渠道(13个)', value: 'all' },
|
|
168
168
|
{ name: '⭐ 主流渠道(facebook, google, tiktok, applovin, ironsource, unity)', value: 'mainstream' },
|
|
169
169
|
{ name: '📄 仅 HTML 渠道(applovin, ironsource, unity, moloco, adikteev, remerge)', value: 'html' },
|
|
170
|
-
{ name: '📦 仅 ZIP 渠道(
|
|
170
|
+
{ name: '📦 仅 ZIP 渠道(tiktok, liftoff, bigo, snapchat, inmobi)', value: 'zip' },
|
|
171
171
|
{ name: '🔧 自定义选择', value: 'custom' },
|
|
172
172
|
],
|
|
173
173
|
},
|
|
@@ -242,7 +242,11 @@ export async function buildAllCommand(projectPath, options) {
|
|
|
242
242
|
spinner.start(pc.cyan('🚀 批量打渠道包...'));
|
|
243
243
|
}
|
|
244
244
|
// 3.5 输入商店跳转地址(CTA)- 必填
|
|
245
|
-
|
|
245
|
+
// 先从配置文件读取 storeUrls(loadBuildConfig 从 build 字段读取)
|
|
246
|
+
const configStoreUrls = fileConfig?.storeUrls;
|
|
247
|
+
let iosUrl = options.iosStoreUrl || configStoreUrls?.ios || '';
|
|
248
|
+
let androidUrl = options.androidStoreUrl || configStoreUrls?.android || '';
|
|
249
|
+
if (!iosUrl || !androidUrl) {
|
|
246
250
|
spinner.stop();
|
|
247
251
|
console.log(pc.cyan('\n🔗 商店跳转地址(CTA 按钮目标)'));
|
|
248
252
|
console.log(pc.dim(' iOS 和 Android 地址均为必填'));
|
|
@@ -251,22 +255,134 @@ export async function buildAllCommand(projectPath, options) {
|
|
|
251
255
|
type: 'input',
|
|
252
256
|
name: 'iosStoreUrl',
|
|
253
257
|
message: 'iOS App Store URL:',
|
|
254
|
-
default:
|
|
258
|
+
default: iosUrl,
|
|
255
259
|
validate: (input) => input.trim() ? true : '请输入 iOS App Store URL',
|
|
256
260
|
},
|
|
257
261
|
{
|
|
258
262
|
type: 'input',
|
|
259
263
|
name: 'androidStoreUrl',
|
|
260
264
|
message: 'Android Google Play URL:',
|
|
261
|
-
default:
|
|
265
|
+
default: androidUrl,
|
|
262
266
|
validate: (input) => input.trim() ? true : '请输入 Android Google Play URL',
|
|
263
267
|
},
|
|
264
268
|
]);
|
|
265
|
-
|
|
266
|
-
|
|
269
|
+
iosUrl = storeUrlAnswer.iosStoreUrl.trim();
|
|
270
|
+
androidUrl = storeUrlAnswer.androidStoreUrl.trim();
|
|
267
271
|
spinner.start(pc.cyan('🚀 批量打渠道包...'));
|
|
268
272
|
}
|
|
269
|
-
//
|
|
273
|
+
// 保存到 options 供后续使用
|
|
274
|
+
options.iosStoreUrl = iosUrl;
|
|
275
|
+
options.androidStoreUrl = androidUrl;
|
|
276
|
+
// 4. 检测引擎 + 构建工具
|
|
277
|
+
let detectionResult;
|
|
278
|
+
if (options.usePlayableScripts) {
|
|
279
|
+
detectionResult = { engine: 'generic', buildTool: 'playable-scripts' };
|
|
280
|
+
console.log(pc.cyan(`\n🔧 使用 @playcraft/devkit 构建工具(用户显式指定)`));
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
detectionResult = await EngineDetector.detectFull(resolvedProjectPath);
|
|
284
|
+
if (detectionResult.buildTool === 'playable-scripts') {
|
|
285
|
+
console.log(pc.cyan(`\n🔧 检测到 @playcraft/devkit 构建工具`));
|
|
286
|
+
console.log(pc.dim(' 将使用 playable-scripts builds 进行批量构建'));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const isPlayableScripts = detectionResult.buildTool === 'playable-scripts';
|
|
290
|
+
// 4.5 如果是 playable-scripts,使用其原生批量构建能力
|
|
291
|
+
if (isPlayableScripts) {
|
|
292
|
+
spinner.text = pc.cyan('使用 @playcraft/devkit 进行批量构建...');
|
|
293
|
+
// 主题选择:CLI 参数 > 交互式选择 > 默认主题
|
|
294
|
+
let selectedThemes;
|
|
295
|
+
const themeDir = path.join(resolvedProjectPath, 'src', 'theme');
|
|
296
|
+
if (options.theme) {
|
|
297
|
+
// 用户通过 --theme 参数指定了主题,清理可能的引号
|
|
298
|
+
const cleanTheme = options.theme.replace(/^["']|["']$/g, '');
|
|
299
|
+
selectedThemes = [cleanTheme];
|
|
300
|
+
console.log(pc.green(`\n✅ 使用指定主题: ${cleanTheme}`));
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
// 交互式选择主题(如果项目有多个主题)
|
|
304
|
+
try {
|
|
305
|
+
const themeEntries = await fs.readdir(themeDir, { withFileTypes: true });
|
|
306
|
+
const availableThemes = themeEntries
|
|
307
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('tiles-'))
|
|
308
|
+
.map(e => e.name)
|
|
309
|
+
.sort();
|
|
310
|
+
if (availableThemes.length > 1) {
|
|
311
|
+
spinner.stop();
|
|
312
|
+
console.log(pc.cyan('\n🎨 选择要构建的主题:'));
|
|
313
|
+
const themeAnswer = await inquirer.prompt([
|
|
314
|
+
{
|
|
315
|
+
type: 'list',
|
|
316
|
+
name: 'theme',
|
|
317
|
+
message: '选择主题:',
|
|
318
|
+
choices: [
|
|
319
|
+
{ name: '📌 使用当前默认主题 (src/theme/index.ts)', value: '__default__' },
|
|
320
|
+
...availableThemes.map(t => ({ name: `🎨 ${t}`, value: t })),
|
|
321
|
+
],
|
|
322
|
+
default: '__default__',
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
if (themeAnswer.theme !== '__default__') {
|
|
326
|
+
selectedThemes = [themeAnswer.theme];
|
|
327
|
+
console.log(pc.green(`✅ 已选择主题: ${themeAnswer.theme}`));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
console.log(pc.dim(' 使用当前默认主题'));
|
|
331
|
+
}
|
|
332
|
+
spinner.start(pc.cyan('使用 @playcraft/devkit 进行批量构建...'));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// 无主题目录,跳过
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// 构建 playableScripts 配置,将选中的渠道和主题传入
|
|
340
|
+
// 商店地址优先级:CLI 参数 > 配置文件 > 交互式输入
|
|
341
|
+
const psConfig = {
|
|
342
|
+
...fileConfig?.playableScripts,
|
|
343
|
+
channels: targetPlatforms,
|
|
344
|
+
outputDir: path.resolve(options.output || './dist'),
|
|
345
|
+
storeUrls: {
|
|
346
|
+
ios: options.iosStoreUrl || '',
|
|
347
|
+
android: options.androidStoreUrl || '',
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
// 如果选择了具体主题,添加到配置
|
|
351
|
+
if (selectedThemes && selectedThemes.length > 0) {
|
|
352
|
+
psConfig.themes = {
|
|
353
|
+
enabled: true,
|
|
354
|
+
whitelist: selectedThemes,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const outputDir = path.resolve(options.output || './dist');
|
|
358
|
+
const baseBuilder = new BaseBuilder(resolvedProjectPath, {
|
|
359
|
+
outputDir,
|
|
360
|
+
}, {
|
|
361
|
+
usePlayableScripts: true,
|
|
362
|
+
playableScriptsConfig: psConfig,
|
|
363
|
+
});
|
|
364
|
+
const baseBuild = await baseBuilder.build();
|
|
365
|
+
spinner.succeed(pc.green('📦 @playcraft/devkit 批量构建完成!'));
|
|
366
|
+
// 显示产物信息
|
|
367
|
+
const outputBaseDir = baseBuild.outputDir;
|
|
368
|
+
console.log('\n' + pc.bold('构建产物:'));
|
|
369
|
+
if (baseBuild.files.assets.length > 0) {
|
|
370
|
+
for (const asset of baseBuild.files.assets) {
|
|
371
|
+
try {
|
|
372
|
+
const stat = await fs.stat(asset);
|
|
373
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
|
|
374
|
+
console.log(` - ${path.relative(outputBaseDir, asset)}: ${sizeMB} MB`);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
console.log(` - ${path.relative(outputBaseDir, asset)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
console.log('\n' + pc.green(`输出目录: ${outputBaseDir}`));
|
|
382
|
+
console.log(pc.dim('💡 产物已包含渠道适配、MRAID 注入、代码混淆和 fflate 压缩'));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// 5. 执行阶段1:Base Build(只执行一次,非 playable-scripts 流程)
|
|
270
386
|
isBaseBuild = await detectBaseBuild(resolvedProjectPath);
|
|
271
387
|
if (isBaseBuild) {
|
|
272
388
|
spinner.text = pc.cyan('✅ 检测到多文件构建产物,跳过阶段1');
|
|
@@ -285,7 +401,7 @@ export async function buildAllCommand(projectPath, options) {
|
|
|
285
401
|
spinner.succeed(pc.green('✅ 阶段1: 基础构建完成'));
|
|
286
402
|
spinner.start(pc.cyan('开始阶段2: 批量渠道打包...'));
|
|
287
403
|
}
|
|
288
|
-
//
|
|
404
|
+
// 6. 检测 Ammo.js 并处理
|
|
289
405
|
const ammoCheck = await detectAmmoUsage(baseBuildDir);
|
|
290
406
|
let ammoReplacement;
|
|
291
407
|
if (options.ammoEngine === 'p2' || options.ammoEngine === 'cannon') {
|
|
@@ -319,7 +435,7 @@ export async function buildAllCommand(projectPath, options) {
|
|
|
319
435
|
}
|
|
320
436
|
spinner.start(pc.cyan('开始阶段2: 批量渠道打包...'));
|
|
321
437
|
}
|
|
322
|
-
//
|
|
438
|
+
// 7. 逐个渠道执行阶段2
|
|
323
439
|
const outputBaseDir = path.resolve(options.output || './dist');
|
|
324
440
|
const results = [];
|
|
325
441
|
const startTime = Date.now();
|
|
@@ -407,7 +523,7 @@ export async function buildAllCommand(projectPath, options) {
|
|
|
407
523
|
}
|
|
408
524
|
const totalDuration = Date.now() - startTime;
|
|
409
525
|
spinner.stop();
|
|
410
|
-
//
|
|
526
|
+
// 8. 输出汇总报告
|
|
411
527
|
const successCount = results.filter(r => r.success).length;
|
|
412
528
|
const failCount = results.filter(r => !r.success).length;
|
|
413
529
|
console.log('\n' + pc.bold('═══════════════════════════════════════════'));
|