pixifact-cli 0.3.0 → 0.4.0

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 CHANGED
@@ -43,6 +43,23 @@ bun run build
43
43
 
44
44
  不要编辑 `.pixifact/generated/**` 下的生成文件。
45
45
 
46
+ ## 微信小游戏 target
47
+
48
+ 项目在 `pixifact.project.json` 声明 `targets.wechat` 后,可运行:
49
+
50
+ ```bash
51
+ pixifact validate --target wechat
52
+ pixifact build --target wechat
53
+ pixifact build --target wechat --mode development
54
+ pixifact dev --target wechat
55
+ ```
56
+
57
+ `build` 默认使用 production mode,压缩 `game.js`;development mode 额外输出 source map。`dev` 固定使用 development mode,并在 Scene、脚本、平台配置或资源变化时重建。
58
+
59
+ 输出目录由 `targets.wechat.outDir` 决定,可直接导入微信开发者工具。CLI 负责 target 校验、Scene 编译、主包资源 hash、资源分包 / HTTPS 远程资源映射、原生配置复制和 4 MiB 主包 / 20 MiB 总包检查,不负责上传、体验版、审核或发布。
60
+
61
+ 完整配置和支持矩阵见 [微信小游戏构建](https://github.com/hxg2050/pixifact/blob/main/docs/zh/wechat-minigame.md),可运行示例见 [wechat-minigame-demo](https://github.com/hxg2050/pixifact/tree/main/sample-projects/wechat-minigame-demo)。
62
+
46
63
  ## 环境要求
47
64
 
48
65
  - Bun
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixifact-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pixifact Scene 自动化 CLI。",
5
5
  "packageManager": "bun@1.3.13",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "start": "bun src/pixifact-cli.ts"
32
32
  },
33
33
  "dependencies": {
34
- "pixifact": "^0.3.0"
34
+ "esbuild": "^0.27.2",
35
+ "pixifact": "^0.4.0"
35
36
  }
36
37
  }
package/src/automation.ts CHANGED
@@ -528,7 +528,7 @@ function createBlankCompilerSceneSource(sceneName: string) {
528
528
  function createBlankCompilerSceneScript(sceneName: string) {
529
529
  return [
530
530
  "import { Group } from 'pixifact/runtime';",
531
- "import { scene } from 'pixifact/compiler';",
531
+ "import { scene } from 'pixifact/scene';",
532
532
  '',
533
533
  '@scene()',
534
534
  `export class ${sceneName} extends Group {}`,
@@ -4,6 +4,13 @@ import { hintForCommandError } from 'pixifact';
4
4
  import { CompileSceneError, compileScenes } from 'pixifact/compiler-node';
5
5
  import type { SceneValidationDiagnostic } from 'pixifact/compiler';
6
6
  import { createLiveBridgeServer } from './liveBridgeServer';
7
+ import {
8
+ buildWechatTarget,
9
+ devWechatTarget,
10
+ validateWechatTarget,
11
+ type WechatDevEvent,
12
+ WechatTargetError,
13
+ } from './wechatTarget';
7
14
 
8
15
  type Automation = ReturnType<typeof createPixifactAutomation>;
9
16
  type LiveBridge = Pick<ReturnType<typeof createLiveBridgeServer>, 'connected' | 'callAction' | 'stop'> & {
@@ -13,6 +20,7 @@ type LiveBridge = Pick<ReturnType<typeof createLiveBridgeServer>, 'connected' |
13
20
  interface CliOptions {
14
21
  automation?: Automation;
15
22
  liveBridge?: LiveBridge;
23
+ onWechatDevEvent?: (event: WechatDevEvent) => void;
16
24
  }
17
25
 
18
26
  interface CliResult {
@@ -205,10 +213,84 @@ function sourcePositionFromMessage(source: string, message: string) {
205
213
  };
206
214
  }
207
215
 
208
- async function executeFileCommand(positionals: string[], flags: Record<string, string | true>, automation: Automation) {
216
+ async function executeFileCommand(
217
+ positionals: string[],
218
+ flags: Record<string, string | true>,
219
+ automation: Automation,
220
+ onWechatDevEvent?: (event: WechatDevEvent) => void,
221
+ ) {
209
222
  const [area, action] = positionals;
210
223
  const projectRoot = projectRootFlag(flags);
211
224
 
225
+ if ((area === 'build' || area === 'dev' || area === 'validate') && action === undefined) {
226
+ const target = requireFlag(flags, 'target');
227
+ if (target !== 'wechat') {
228
+ throw new Error(`Unknown Pixifact target "${target}".`);
229
+ }
230
+ if (area === 'validate') {
231
+ const result = await validateWechatTarget(projectRoot);
232
+ return result.diagnostics.length > 0
233
+ ? {
234
+ ok: false,
235
+ target,
236
+ diagnostics: result.diagnostics,
237
+ hint: 'Fix the listed WeChat target diagnostics, then validate again.',
238
+ }
239
+ : { ok: true, projectRoot, target };
240
+ }
241
+ if (area === 'dev') {
242
+ if (flags.mode !== undefined) {
243
+ throw new Error('dev --target wechat always uses development mode; remove --mode.');
244
+ }
245
+ try {
246
+ const session = await devWechatTarget(projectRoot, onWechatDevEvent);
247
+ return {
248
+ ok: true,
249
+ projectRoot,
250
+ target,
251
+ mode: 'development',
252
+ watching: true,
253
+ report: session.initialReport,
254
+ };
255
+ } catch (error) {
256
+ if (error instanceof WechatTargetError) {
257
+ return {
258
+ ok: false,
259
+ target,
260
+ error: error.message,
261
+ diagnostics: error.diagnostics,
262
+ hint: 'Fix the listed WeChat target diagnostics, then start dev again.',
263
+ };
264
+ }
265
+ throw error;
266
+ }
267
+ }
268
+ const mode = flags.mode === undefined ? 'production' : requireFlag(flags, 'mode');
269
+ if (mode !== 'development' && mode !== 'production') {
270
+ throw new Error('--mode must be development or production.');
271
+ }
272
+ try {
273
+ return {
274
+ ok: true,
275
+ projectRoot,
276
+ target,
277
+ mode,
278
+ report: await buildWechatTarget(projectRoot, mode),
279
+ };
280
+ } catch (error) {
281
+ if (error instanceof WechatTargetError) {
282
+ return {
283
+ ok: false,
284
+ target,
285
+ error: error.message,
286
+ diagnostics: error.diagnostics,
287
+ hint: 'Fix the listed WeChat target diagnostics, then build again.',
288
+ };
289
+ }
290
+ throw error;
291
+ }
292
+ }
293
+
212
294
  if (area === 'compile-scenes' && action === undefined) {
213
295
  try {
214
296
  await compileScenes({ projectRoot });
@@ -305,6 +387,9 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
305
387
  'scene inspect --scene <scene-path>',
306
388
  'scene validate --scene <scene-path>',
307
389
  'compile-scenes',
390
+ 'validate --target wechat',
391
+ 'build --target wechat',
392
+ 'dev --target wechat',
308
393
  ],
309
394
  aiValidationAlternatives: [
310
395
  'scene validate --all',
@@ -334,7 +419,12 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
334
419
  parsed.flags,
335
420
  options.liveBridge ?? (ownedBridge = createLiveBridgeServer()),
336
421
  )
337
- : await executeFileCommand(parsed.positionals, parsed.flags, automation);
422
+ : await executeFileCommand(
423
+ parsed.positionals,
424
+ parsed.flags,
425
+ automation,
426
+ options.onWechatDevEvent,
427
+ );
338
428
  if (isFailedResult(result)) {
339
429
  return {
340
430
  exitCode: 1,
@@ -363,7 +453,9 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
363
453
  }
364
454
 
365
455
  if (import.meta.url === `file://${process.argv[1]}`) {
366
- const result = await executePixifactCli(process.argv.slice(2));
456
+ const result = await executePixifactCli(process.argv.slice(2), {
457
+ onWechatDevEvent: (event) => process.stdout.write(jsonLine(event)),
458
+ });
367
459
  process.stdout.write(result.stdout);
368
460
  process.stderr.write(result.stderr);
369
461
  process.exitCode = result.exitCode;
@@ -0,0 +1,578 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { watch as watchFiles, type FSWatcher } from 'node:fs';
3
+ import { access, cp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { build, type BuildOptions, type Plugin } from 'esbuild';
6
+ import {
7
+ parsePixifactProjectConfig,
8
+ pixifactProjectConfigFileName,
9
+ type PixifactProjectConfig,
10
+ type PixifactWechatTargetConfig,
11
+ } from 'pixifact';
12
+ import {
13
+ compilerSceneNodeLocator,
14
+ parseSceneTemplate,
15
+ type SceneTemplateNode,
16
+ } from 'pixifact/compiler';
17
+ import { compileScenes, generatedSceneAssetsFileName } from 'pixifact/compiler-node';
18
+
19
+ const wechatMainPackageLimit = 4 * 1024 * 1024;
20
+ const wechatTotalPackageLimit = 20 * 1024 * 1024;
21
+ const supportedWechatTextureExtensions = new Set(['.jpeg', '.jpg', '.png', '.webp']);
22
+
23
+ export interface WechatTargetDiagnostic {
24
+ asset?: string;
25
+ message: string;
26
+ node?: string;
27
+ scene?: string;
28
+ }
29
+
30
+ export interface WechatBuildReport {
31
+ mainPackageBytes: number;
32
+ outputDirectory: string;
33
+ subpackages: Record<string, number>;
34
+ totalPackageBytes: number;
35
+ }
36
+
37
+ export type WechatDevEvent = {
38
+ type: 'built';
39
+ report: WechatBuildReport;
40
+ } | {
41
+ type: 'error';
42
+ error: string;
43
+ diagnostics?: WechatTargetDiagnostic[];
44
+ };
45
+
46
+ export interface WechatDevSession {
47
+ close(): void;
48
+ initialReport: WechatBuildReport;
49
+ }
50
+
51
+ export class WechatTargetError extends Error {
52
+ constructor(
53
+ message: string,
54
+ readonly diagnostics: WechatTargetDiagnostic[],
55
+ ) {
56
+ super(message);
57
+ this.name = 'WechatTargetError';
58
+ }
59
+ }
60
+
61
+ interface WechatTargetContext {
62
+ config: PixifactProjectConfig;
63
+ projectRoot: string;
64
+ target: PixifactWechatTargetConfig;
65
+ }
66
+
67
+ interface SceneAssetLocation {
68
+ pack?: string;
69
+ src: string;
70
+ }
71
+
72
+ interface SceneAssetReference {
73
+ asset: string;
74
+ node: string;
75
+ scene: string;
76
+ }
77
+
78
+ export async function validateWechatTarget(projectRootInput: string) {
79
+ const context = await readTargetContext(projectRootInput);
80
+ const diagnostics = await collectWechatDiagnostics(context);
81
+ return { context, diagnostics };
82
+ }
83
+
84
+ export async function buildWechatTarget(
85
+ projectRootInput: string,
86
+ mode: 'development' | 'production',
87
+ ): Promise<WechatBuildReport> {
88
+ const { context, diagnostics } = await validateWechatTarget(projectRootInput);
89
+ if (diagnostics.length > 0) {
90
+ throw new WechatTargetError('WeChat target validation failed.', diagnostics);
91
+ }
92
+ await compileScenes({ projectRoot: context.projectRoot });
93
+ const generatedDir = path.join(context.projectRoot, '.pixifact', 'generated');
94
+ const assets = JSON.parse(
95
+ await readFile(path.join(generatedDir, generatedSceneAssetsFileName), 'utf8'),
96
+ ) as string[];
97
+ const outputDirectory = path.resolve(context.projectRoot, context.target.outDir);
98
+ assertSafeOutputDirectory(context, outputDirectory);
99
+ await rm(outputDirectory, { force: true, recursive: true });
100
+ await mkdir(outputDirectory, { recursive: true });
101
+
102
+ const manifest = await emitTargetAssets(context, assets, outputDirectory);
103
+ await copyWechatConfiguration(context, outputDirectory);
104
+ await build(wechatBuildOptions(context, outputDirectory, manifest, mode));
105
+ const report = await createBuildReport(context, outputDirectory);
106
+ const sizeDiagnostics: WechatTargetDiagnostic[] = [];
107
+ if (report.mainPackageBytes > wechatMainPackageLimit) {
108
+ sizeDiagnostics.push({ message: `微信主包为 ${report.mainPackageBytes} bytes,超过 4 MiB 限制。` });
109
+ }
110
+ if (report.totalPackageBytes > wechatTotalPackageLimit) {
111
+ sizeDiagnostics.push({ message: `微信代码包总计 ${report.totalPackageBytes} bytes,超过 20 MiB 限制。` });
112
+ }
113
+ if (sizeDiagnostics.length > 0) {
114
+ throw new WechatTargetError('WeChat package size validation failed.', sizeDiagnostics);
115
+ }
116
+ return report;
117
+ }
118
+
119
+ export async function devWechatTarget(
120
+ projectRootInput: string,
121
+ onEvent: (event: WechatDevEvent) => void = () => undefined,
122
+ ): Promise<WechatDevSession> {
123
+ const initialReport = await buildWechatTarget(projectRootInput, 'development');
124
+ const { context } = await validateWechatTarget(projectRootInput);
125
+ const ignoredPaths = new Set([
126
+ '.git',
127
+ '.pixifact',
128
+ 'node_modules',
129
+ context.target.outDir,
130
+ ]);
131
+ let watcher: FSWatcher;
132
+ let debounce: ReturnType<typeof setTimeout> | undefined;
133
+ let building = false;
134
+ let queued = false;
135
+ let closed = false;
136
+
137
+ const rebuild = async () => {
138
+ if (closed) {
139
+ return;
140
+ }
141
+ if (building) {
142
+ queued = true;
143
+ return;
144
+ }
145
+ building = true;
146
+ try {
147
+ const report = await buildWechatTarget(context.projectRoot, 'development');
148
+ ignoredPaths.add(projectPath(context.projectRoot, report.outputDirectory));
149
+ onEvent({ type: 'built', report });
150
+ } catch (error) {
151
+ onEvent(wechatDevErrorEvent(error));
152
+ } finally {
153
+ building = false;
154
+ if (queued) {
155
+ queued = false;
156
+ void rebuild();
157
+ }
158
+ }
159
+ };
160
+ watcher = watchFiles(context.projectRoot, { recursive: true }, (_eventType, filename) => {
161
+ const relativePath = filename?.toString().replaceAll(path.sep, '/');
162
+ if (!relativePath || [...ignoredPaths].some((ignored) =>
163
+ relativePath === ignored || relativePath.startsWith(`${ignored}/`)
164
+ )) {
165
+ return;
166
+ }
167
+ clearTimeout(debounce);
168
+ debounce = setTimeout(() => void rebuild(), 80);
169
+ });
170
+
171
+ return {
172
+ close() {
173
+ if (closed) {
174
+ return;
175
+ }
176
+ closed = true;
177
+ clearTimeout(debounce);
178
+ watcher.close();
179
+ },
180
+ initialReport,
181
+ };
182
+ }
183
+
184
+ function wechatDevErrorEvent(error: unknown): WechatDevEvent {
185
+ return error instanceof WechatTargetError
186
+ ? { type: 'error', error: error.message, diagnostics: error.diagnostics }
187
+ : { type: 'error', error: error instanceof Error ? error.message : String(error) };
188
+ }
189
+
190
+ async function readTargetContext(projectRootInput: string): Promise<WechatTargetContext> {
191
+ const projectRoot = path.resolve(projectRootInput);
192
+ const config = parsePixifactProjectConfig(JSON.parse(
193
+ await readFile(path.join(projectRoot, pixifactProjectConfigFileName), 'utf8'),
194
+ ));
195
+ const target = config.targets?.wechat;
196
+ if (!target) {
197
+ throw new Error('pixifact.project.json does not declare targets.wechat.');
198
+ }
199
+ return { config, projectRoot, target };
200
+ }
201
+
202
+ function assertSafeOutputDirectory(context: WechatTargetContext, outputDirectory: string) {
203
+ if (outputDirectory === context.projectRoot || !outputDirectory.startsWith(`${context.projectRoot}${path.sep}`)) {
204
+ throw new Error('targets.wechat.outDir must be a directory inside projectRoot.');
205
+ }
206
+ const inputs = [
207
+ '.pixifact',
208
+ context.target.entry,
209
+ context.target.configDir,
210
+ ...Object.values(context.config.scenes),
211
+ ...Object.values(context.config.resourcePacks ?? {}).map((pack) => pack.root),
212
+ ];
213
+ const outDir = context.target.outDir.replaceAll(path.sep, '/').replace(/\/+$/, '');
214
+ const containedInput = inputs.find((input) => input === outDir || input.startsWith(`${outDir}/`));
215
+ if (containedInput) {
216
+ throw new Error(`targets.wechat.outDir must not contain project input "${containedInput}".`);
217
+ }
218
+ }
219
+
220
+ async function collectWechatDiagnostics(context: WechatTargetContext) {
221
+ const diagnostics: WechatTargetDiagnostic[] = [];
222
+ const sceneAssets: SceneAssetReference[] = [];
223
+ await requireFile(context, context.target.entry, diagnostics, '微信入口不存在。');
224
+ await requireFile(
225
+ context,
226
+ path.posix.join(context.target.configDir, 'game.json'),
227
+ diagnostics,
228
+ '微信 game.json 不存在。',
229
+ );
230
+ await requireFile(
231
+ context,
232
+ path.posix.join(context.target.configDir, 'project.config.json'),
233
+ diagnostics,
234
+ '微信 project.config.json 不存在。',
235
+ );
236
+ for (const [name, pack] of Object.entries(context.config.resourcePacks ?? {})) {
237
+ if (!context.target.resourcePacks[name]) {
238
+ diagnostics.push({ message: `微信 target 未声明资源包 ${name} 的 delivery。` });
239
+ }
240
+ const root = path.resolve(context.projectRoot, pack.root);
241
+ if (!await isDirectory(root)) {
242
+ diagnostics.push({ asset: pack.root, message: `资源包 ${name} 的根目录不存在。` });
243
+ continue;
244
+ }
245
+ for (const file of await collectFiles(root)) {
246
+ if (path.extname(file).toLowerCase() === '.svg') {
247
+ diagnostics.push({
248
+ asset: projectPath(context.projectRoot, file),
249
+ message: '微信小游戏目标不支持 SVG 资源。',
250
+ });
251
+ }
252
+ }
253
+ }
254
+ for (const [name, delivery] of Object.entries(context.target.resourcePacks)) {
255
+ if (delivery.delivery === 'remote' && !isHttpsUrl(delivery.baseUrl)) {
256
+ diagnostics.push({ message: `远程资源包 ${name} 的 baseUrl 必须使用 HTTPS。` });
257
+ }
258
+ }
259
+ for (const scene of await collectFiles(path.join(context.projectRoot, 'src'), '.scene')) {
260
+ const scenePath = projectPath(context.projectRoot, scene);
261
+ const template = parseSceneTemplate(await readFile(scene, 'utf8'));
262
+ collectSceneDiagnostics(template.children, scenePath, '', diagnostics, sceneAssets);
263
+ }
264
+ for (const reference of sceneAssets) {
265
+ if (!await exists(path.join(context.projectRoot, reference.asset))) {
266
+ diagnostics.push({
267
+ ...reference,
268
+ message: '微信小游戏 Scene 纹理文件不存在。',
269
+ });
270
+ }
271
+ }
272
+ await validateWechatSubpackages(context, diagnostics);
273
+ return diagnostics;
274
+ }
275
+
276
+ function isHttpsUrl(value: string) {
277
+ try {
278
+ const url = new URL(value);
279
+ return url.protocol === 'https:' && url.hostname !== '';
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ function collectSceneDiagnostics(
286
+ nodes: SceneTemplateNode[],
287
+ scene: string,
288
+ parentPath: string,
289
+ diagnostics: WechatTargetDiagnostic[],
290
+ assets: SceneAssetReference[],
291
+ ) {
292
+ for (const [index, node] of nodes.entries()) {
293
+ const nodePath = parentPath ? `${parentPath}/${index}` : String(index);
294
+ if (node.kind === 'slotOutlet') {
295
+ continue;
296
+ }
297
+ if (node.kind === 'sceneInstance') {
298
+ for (const [slotName, children] of Object.entries(node.slots)) {
299
+ collectSceneDiagnostics(children, scene, `${nodePath}/slot:${slotName}`, diagnostics, assets);
300
+ }
301
+ continue;
302
+ }
303
+ const locator = compilerSceneNodeLocator(node, nodePath);
304
+ if (node.type === 'HTMLText' || node.type === 'DOMContainer') {
305
+ diagnostics.push({
306
+ message: `微信小游戏目标不支持 ${node.type}。`,
307
+ node: locator,
308
+ scene,
309
+ });
310
+ }
311
+ if (typeof node.props.texture === 'string') {
312
+ const texture = node.props.texture;
313
+ if (!isProjectAssetPath(texture)) {
314
+ diagnostics.push({
315
+ asset: texture,
316
+ message: '微信小游戏 Scene 纹理必须使用项目根目录内的相对路径。',
317
+ node: locator,
318
+ scene,
319
+ });
320
+ } else if (!supportedWechatTextureExtensions.has(path.extname(texture).toLowerCase())) {
321
+ const extension = path.extname(texture).toLowerCase();
322
+ diagnostics.push({
323
+ asset: texture,
324
+ message: `微信小游戏 Scene 纹理不支持 ${extension || '无扩展名'} 格式。`,
325
+ node: locator,
326
+ scene,
327
+ });
328
+ } else {
329
+ assets.push({ asset: texture, node: locator, scene });
330
+ }
331
+ }
332
+ collectSceneDiagnostics(node.children, scene, nodePath, diagnostics, assets);
333
+ }
334
+ }
335
+
336
+ function isProjectAssetPath(value: string) {
337
+ return value.trim() !== ''
338
+ && !value.startsWith('/')
339
+ && !value.startsWith('./')
340
+ && !value.includes('\\')
341
+ && !value.split('/').includes('..')
342
+ && !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
343
+ }
344
+
345
+ async function validateWechatSubpackages(
346
+ context: WechatTargetContext,
347
+ diagnostics: WechatTargetDiagnostic[],
348
+ ) {
349
+ const gameJsonPath = path.join(context.projectRoot, context.target.configDir, 'game.json');
350
+ if (!await exists(gameJsonPath)) {
351
+ return;
352
+ }
353
+ const gameJson = JSON.parse(await readFile(gameJsonPath, 'utf8')) as {
354
+ subpackages?: Array<{ name?: string; root?: string }>;
355
+ };
356
+ for (const [name, delivery] of Object.entries(context.target.resourcePacks)) {
357
+ if (delivery.delivery !== 'subpackage') {
358
+ continue;
359
+ }
360
+ const declaration = gameJson.subpackages?.find((subpackage) => subpackage.name === name);
361
+ if (!declaration || declaration.root !== delivery.root) {
362
+ diagnostics.push({
363
+ message: `game.json 必须声明分包 ${name},root 为 ${delivery.root}。`,
364
+ });
365
+ }
366
+ }
367
+ }
368
+
369
+ async function emitTargetAssets(
370
+ context: WechatTargetContext,
371
+ assets: string[],
372
+ outputDirectory: string,
373
+ ) {
374
+ const manifest: Record<string, SceneAssetLocation> = {};
375
+ for (const [name, pack] of Object.entries(context.config.resourcePacks ?? {})) {
376
+ const delivery = context.target.resourcePacks[name];
377
+ if (delivery.delivery !== 'subpackage') {
378
+ continue;
379
+ }
380
+ const source = path.join(context.projectRoot, pack.root);
381
+ const destination = path.join(outputDirectory, delivery.root);
382
+ await cp(source, destination, { recursive: true });
383
+ const entry = path.join(destination, 'game.js');
384
+ if (!await exists(entry)) {
385
+ await writeFile(entry, '// Pixifact asset-only subpackage.\n');
386
+ }
387
+ }
388
+ for (const asset of assets) {
389
+ const source = path.resolve(context.projectRoot, asset);
390
+ const pack = resourcePackFor(context, asset);
391
+ if (pack) {
392
+ const sourceRoot = context.config.resourcePacks![pack.name].root;
393
+ const relative = path.posix.relative(sourceRoot, asset);
394
+ const delivery = pack.delivery;
395
+ manifest[asset] = delivery.delivery === 'subpackage'
396
+ ? { pack: pack.name, src: path.posix.join(delivery.root, relative) }
397
+ : { src: `${delivery.baseUrl}/${relative}` };
398
+ continue;
399
+ }
400
+ const content = await readFile(source);
401
+ const extension = path.extname(asset);
402
+ const basename = path.basename(asset, extension);
403
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
404
+ const output = path.posix.join('assets', `${basename}-${hash}${extension}`);
405
+ await mkdir(path.dirname(path.join(outputDirectory, output)), { recursive: true });
406
+ await writeFile(path.join(outputDirectory, output), content);
407
+ manifest[asset] = { src: output };
408
+ }
409
+ return manifest;
410
+ }
411
+
412
+ function resourcePackFor(context: WechatTargetContext, asset: string) {
413
+ const matches = Object.entries(context.config.resourcePacks ?? {}).filter(([, pack]) =>
414
+ asset === pack.root || asset.startsWith(`${pack.root}/`)
415
+ );
416
+ if (matches.length > 1) {
417
+ throw new Error(`Scene asset "${asset}" matches multiple resource packs.`);
418
+ }
419
+ const match = matches[0];
420
+ return match
421
+ ? { name: match[0], delivery: context.target.resourcePacks[match[0]] }
422
+ : undefined;
423
+ }
424
+
425
+ async function copyWechatConfiguration(context: WechatTargetContext, outputDirectory: string) {
426
+ for (const file of ['game.json', 'project.config.json']) {
427
+ await cp(
428
+ path.join(context.projectRoot, context.target.configDir, file),
429
+ path.join(outputDirectory, file),
430
+ );
431
+ }
432
+ }
433
+
434
+ function wechatBuildOptions(
435
+ context: WechatTargetContext,
436
+ outputDirectory: string,
437
+ manifest: Record<string, SceneAssetLocation>,
438
+ mode: 'development' | 'production',
439
+ ): BuildOptions {
440
+ return {
441
+ absWorkingDir: context.projectRoot,
442
+ assetNames: 'assets/[name]-[hash]',
443
+ banner: {
444
+ js: [
445
+ 'var Intl=globalThis.Intl||{};',
446
+ 'var navigator=globalThis.navigator||(globalThis.navigator={gpu:null,userAgent:"WeChatMiniGame"});',
447
+ ].join('\n'),
448
+ },
449
+ bundle: true,
450
+ define: {
451
+ 'process.env.NODE_ENV': JSON.stringify(mode),
452
+ },
453
+ entryPoints: [path.resolve(context.projectRoot, context.target.entry)],
454
+ format: 'iife',
455
+ legalComments: 'none',
456
+ loader: {
457
+ '.jpeg': 'file',
458
+ '.jpg': 'file',
459
+ '.mp3': 'file',
460
+ '.ogg': 'file',
461
+ '.png': 'file',
462
+ '.wav': 'file',
463
+ '.webp': 'file',
464
+ },
465
+ logLevel: 'silent',
466
+ minify: mode === 'production',
467
+ outfile: path.join(outputDirectory, 'game.js'),
468
+ platform: 'browser',
469
+ plugins: [wechatScenesPlugin(context, manifest)],
470
+ sourcemap: mode === 'development',
471
+ target: 'es2020',
472
+ };
473
+ }
474
+
475
+ function wechatScenesPlugin(
476
+ context: WechatTargetContext,
477
+ manifest: Record<string, SceneAssetLocation>,
478
+ ): Plugin {
479
+ return {
480
+ name: 'pixifact-wechat-scenes',
481
+ setup(buildContext) {
482
+ buildContext.onResolve({ filter: /^pixifact:scenes$/ }, () => ({
483
+ namespace: 'pixifact-wechat',
484
+ path: 'scenes',
485
+ }));
486
+ buildContext.onLoad({ filter: /^scenes$/, namespace: 'pixifact-wechat' }, () => ({
487
+ contents: [
488
+ "import { configureSceneAssets } from 'pixifact/scene';",
489
+ "import { loadWechatSubpackage } from 'pixifact/platform/wechat';",
490
+ `import ${JSON.stringify(path.join(context.projectRoot, '.pixifact', 'generated', 'scenes.generated.ts'))};`,
491
+ `configureSceneAssets(${JSON.stringify(manifest)}, { loadPack: loadWechatSubpackage });`,
492
+ ].join('\n'),
493
+ loader: 'ts',
494
+ resolveDir: context.projectRoot,
495
+ }));
496
+ },
497
+ };
498
+ }
499
+
500
+ async function createBuildReport(
501
+ context: WechatTargetContext,
502
+ outputDirectory: string,
503
+ ): Promise<WechatBuildReport> {
504
+ const subpackageRoots = Object.fromEntries(
505
+ Object.entries(context.target.resourcePacks)
506
+ .filter(([, delivery]) => delivery.delivery === 'subpackage')
507
+ .map(([name, delivery]) => [name, delivery.delivery === 'subpackage' ? delivery.root : '']),
508
+ );
509
+ const subpackages = Object.fromEntries(Object.keys(subpackageRoots).map((name) => [name, 0]));
510
+ let mainPackageBytes = 0;
511
+ let totalPackageBytes = 0;
512
+ const files = await collectFiles(outputDirectory);
513
+ for (const file of files) {
514
+ if (file.endsWith('.map') || file.endsWith('project.config.json')) {
515
+ continue;
516
+ }
517
+ const bytes = (await stat(file)).size;
518
+ const relative = projectPath(outputDirectory, file);
519
+ const subpackage = Object.entries(subpackageRoots).find(([, root]) =>
520
+ relative === root || relative.startsWith(`${root}/`)
521
+ );
522
+ if (subpackage) {
523
+ subpackages[subpackage[0]] += bytes;
524
+ } else {
525
+ mainPackageBytes += bytes;
526
+ }
527
+ totalPackageBytes += bytes;
528
+ }
529
+ return { mainPackageBytes, outputDirectory, subpackages, totalPackageBytes };
530
+ }
531
+
532
+ async function requireFile(
533
+ context: WechatTargetContext,
534
+ relativePath: string,
535
+ diagnostics: WechatTargetDiagnostic[],
536
+ message: string,
537
+ ) {
538
+ if (!await exists(path.join(context.projectRoot, relativePath))) {
539
+ diagnostics.push({ asset: relativePath, message });
540
+ }
541
+ }
542
+
543
+ async function collectFiles(root: string, suffix?: string) {
544
+ const files: string[] = [];
545
+ if (!await isDirectory(root)) {
546
+ return files;
547
+ }
548
+ for (const entry of await readdir(root, { withFileTypes: true })) {
549
+ const absolute = path.join(root, entry.name);
550
+ if (entry.isDirectory()) {
551
+ files.push(...await collectFiles(absolute, suffix));
552
+ } else if (entry.isFile() && (!suffix || entry.name.endsWith(suffix))) {
553
+ files.push(absolute);
554
+ }
555
+ }
556
+ return files;
557
+ }
558
+
559
+ async function exists(filePath: string) {
560
+ try {
561
+ await access(filePath);
562
+ return true;
563
+ } catch {
564
+ return false;
565
+ }
566
+ }
567
+
568
+ async function isDirectory(filePath: string) {
569
+ try {
570
+ return (await stat(filePath)).isDirectory();
571
+ } catch {
572
+ return false;
573
+ }
574
+ }
575
+
576
+ function projectPath(root: string, filePath: string) {
577
+ return path.relative(root, filePath).replaceAll(path.sep, '/');
578
+ }