pixifact-cli 0.6.1 → 0.8.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 youxia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -38,6 +38,7 @@ pixifact scene validate --all
38
38
  ```bash
39
39
  pixifact runtime list
40
40
  pixifact runtime tree --output .pixifact/runtime/tree.json
41
+ pixifact runtime screenshot
41
42
  pixifact runtime node <pixi-uid>
42
43
  pixifact runtime state
43
44
  pixifact runtime logs --after <seq>
@@ -49,6 +50,8 @@ pixifact runtime input key Space
49
50
 
50
51
  `runtime tree` 默认将节点树输出到终端;传入 `--output <json-path>` 时,改为保存运行时快照 JSON,终端只返回路径和采集元数据。快照包含 `schemaVersion`、`capturedAt`、当前 `runtimeId` 和 `root` 节点树,适合 Agent 使用文本搜索或 JSON 工具分析。快照中的 `runtimeId` 和 PixiJS `uid` 只对本次运行有效,不应写回项目源文件。
51
52
 
53
+ `runtime screenshot` 默认将当前 PixiJS Canvas 的 PNG 截图写入项目根下的 `.pixifact/runtime/frame.png`,也可用 `--output <png-path>` 指定路径。截图使用当前 `app.screen` 逻辑尺寸和 renderer 背景色,只包含 `app.stage` 的 PixiJS 渲染内容,不包含浏览器 UI、HTML/CSS 或 DOM overlay;多个页面时添加 `--runtime <runtime-id>`。
54
+
52
55
  ## 浏览器 Editor
53
56
 
54
57
  在目标项目根目录运行:
@@ -82,22 +85,35 @@ bun run build
82
85
 
83
86
  不要编辑 `.pixifact/generated/**` 下的生成文件。
84
87
 
85
- ## 微信小游戏 target
88
+ ## Web / 微信 / 抖音统一构建
89
+
90
+ 三个平台共享 `src/main.ts` 和 Vite 配置。平台由标准 Vite mode env 文件选择,例如 `.env.wechat`:
91
+
92
+ ```ini
93
+ VITE_PLATFORM=wechat
94
+ VITE_APP_ID=wx123456
95
+ ```
86
96
 
87
- 项目在 `pixifact.project.json` 声明 `targets.wechat` 后,可运行:
97
+ 微信项目安装 `@pixifact/platform-wechat` 后运行:
88
98
 
89
99
  ```bash
90
- pixifact validate --target wechat
91
- pixifact build --target wechat
92
- pixifact build --target wechat --mode development
93
- pixifact dev --target wechat
100
+ pixifact validate --mode wechat
101
+ pixifact build --mode wechat
102
+ pixifact dev --mode wechat
103
+ ```
104
+
105
+ 抖音项目安装 `@pixifact/platform-douyin` 后运行:
106
+
107
+ pixifact validate --mode douyin
108
+ pixifact build --mode douyin
109
+ pixifact dev --mode douyin
94
110
  ```
95
111
 
96
- `build` 默认使用 production mode,压缩 `game.js`;development mode 额外输出 source map。`dev` 固定使用 development mode,并在 Scene、脚本、平台配置或资源变化时重建。
112
+ Web mode 只需要 `pixifact`,不安装小游戏平台包也能构建。`dev` 默认 mode 为 `development`,`build` 和 `validate` 默认为 `production`;显式 `--mode` 原样交给 Vite。默认产物目录为 `dist/<platform>/`,需要修改时使用 Vite `build.outDir`。
97
113
 
98
- 输出目录由 `targets.wechat.outDir` 决定,可直接导入微信开发者工具。CLI 负责 target 校验、Scene 编译、主包资源 hash、资源分包 / HTTPS 远程资源映射、原生配置复制和 4 MiB 主包 / 20 MiB 总包检查,不负责上传、体验版、审核或发布。
114
+ CLI 使用 Vite 完成 env、TypeScript、tree-shaking、watch、静态资源和产物生命周期;Pixifact 插件负责 Scene 编译、当前平台虚拟模块、Pixi manifest、资源分包、原生配置和包体检查。业务代码只使用 PixiJS `Assets`,不需要手动加载分包。
99
115
 
100
- 完整配置和支持矩阵见 [微信小游戏构建](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)。
116
+ 完整配置见 [微信小游戏构建](https://github.com/hxg2050/pixifact/blob/main/docs/zh/wechat-minigame.md) 和 [抖音小游戏构建](https://github.com/hxg2050/pixifact/blob/main/docs/zh/douyin-minigame.md),可运行三端示例见 [wechat-minigame-demo](https://github.com/hxg2050/pixifact/tree/main/sample-projects/wechat-minigame-demo)。
101
117
 
102
118
  ## 环境要求
103
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixifact-cli",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Pixifact Scene 自动化 CLI。",
5
5
  "packageManager": "bun@1.3.13",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "start": "bun src/pixifact-cli.ts"
33
33
  },
34
34
  "dependencies": {
35
- "esbuild": "^0.27.2",
36
- "pixifact": "^0.6.1"
35
+ "pixifact": "^0.8.0",
36
+ "vite": "^8.0.10"
37
37
  }
38
38
  }
package/src/automation.ts CHANGED
@@ -465,7 +465,7 @@ function walkFiles(root: string, directory: string, depth: number, maxDepth: num
465
465
 
466
466
  for (const entry of entries) {
467
467
  const absolute = path.join(directory, entry.name);
468
- const relative = path.relative(root, absolute);
468
+ const relative = toPosixPath(path.relative(root, absolute));
469
469
  if (entry.isDirectory()) {
470
470
  results.push({
471
471
  path: relative,
@@ -274,7 +274,12 @@ export function watchEditorProject(projectRoot: string, onProjectFileChanged: (p
274
274
  const changedPathTimers = new Map<string, ReturnType<typeof setTimeout>>();
275
275
  const watcher = fs.watch(projectRoot, { recursive: true }, (_event, fileName) => {
276
276
  if (!fileName) return;
277
- const changedPath = String(fileName).split(path.sep).join('/');
277
+ const filePath = String(fileName);
278
+ const absolutePath = path.resolve(projectRoot, filePath);
279
+ if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) {
280
+ return;
281
+ }
282
+ const changedPath = filePath.split(path.sep).join('/');
278
283
  const pending = changedPathTimers.get(changedPath);
279
284
  if (pending) clearTimeout(pending);
280
285
  changedPathTimers.set(changedPath, setTimeout(() => {
@@ -1,28 +1,30 @@
1
1
  #!/usr/bin/env bun
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
4
5
  import { createPixifactAutomation } from './automation';
5
6
  import { hintForCommandError } from 'pixifact';
6
7
  import { CompileSceneError, compileScenes } from 'pixifact/compiler-node';
7
8
  import type { SceneValidationDiagnostic } from 'pixifact/compiler';
8
9
  import { startEditorServer } from './editorServer';
9
10
  import { captureEditorScreenshot, queryEditorContext } from './editorSession';
10
- import { queryRuntime, queryRuntimeList } from './runtimeSession';
11
+ import { captureRuntimeScreenshot, queryRuntime, queryRuntimeList } from './runtimeSession';
11
12
  import type { RuntimeLogLevel, RuntimeRequest } from 'pixifact/runtime-dev';
12
13
  import {
13
- buildWechatTarget,
14
- devWechatTarget,
15
- validateWechatTarget,
16
- type WechatDevEvent,
17
- WechatTargetError,
18
- } from './wechatTarget';
14
+ buildPixifactTarget,
15
+ devPixifactTarget,
16
+ validatePixifactTarget,
17
+ type PixifactDevEvent,
18
+ PixifactTargetError,
19
+ } from './viteTarget';
19
20
 
20
21
  type Automation = ReturnType<typeof createPixifactAutomation>;
21
22
 
22
23
  interface CliOptions {
23
24
  automation?: Automation;
24
25
  captureEditorScreenshot?: typeof captureEditorScreenshot;
25
- onWechatDevEvent?: (event: WechatDevEvent) => void;
26
+ captureRuntimeScreenshot?: typeof captureRuntimeScreenshot;
27
+ onDevEvent?: (event: PixifactDevEvent) => void;
26
28
  readEditorContext?: typeof queryEditorContext;
27
29
  listRuntimes?: typeof queryRuntimeList;
28
30
  queryRuntime?: typeof queryRuntime;
@@ -45,6 +47,8 @@ interface ParsedArgs {
45
47
  flags: Record<string, string | true>;
46
48
  }
47
49
 
50
+ const runtimeScreenshotDefaultOutput = '.pixifact/runtime/frame.png';
51
+
48
52
  function parseArgs(argv: string[]): ParsedArgs {
49
53
  const positionals: string[] = [];
50
54
  const flags: Record<string, string | true> = {};
@@ -94,6 +98,17 @@ function optionalFlag(flags: Record<string, string | true>, name: string) {
94
98
  return value === undefined ? undefined : requireFlag(flags, name);
95
99
  }
96
100
 
101
+ function assertAllowedFlags(
102
+ flags: Record<string, string | true>,
103
+ allowed: readonly string[],
104
+ command: string,
105
+ ) {
106
+ const unexpected = Object.keys(flags).find((name) => !allowed.includes(name));
107
+ if (unexpected !== undefined) {
108
+ throw new Error(`Unknown option "--${unexpected}" for Pixifact ${command}.`);
109
+ }
110
+ }
111
+
97
112
  function finiteNumberFlag(flags: Record<string, string | true>, name: string) {
98
113
  const value = Number(requireFlag(flags, name));
99
114
  if (!Number.isFinite(value)) throw new Error(`--${name} must be a finite number.`);
@@ -256,12 +271,13 @@ async function executeFileCommand(
256
271
  positionals: string[],
257
272
  flags: Record<string, string | true>,
258
273
  automation: Automation,
259
- onWechatDevEvent?: (event: WechatDevEvent) => void,
274
+ onDevEvent?: (event: PixifactDevEvent) => void,
260
275
  startEditor: typeof startEditorServer = startEditorServer,
261
276
  readEditorContext: typeof queryEditorContext = queryEditorContext,
262
277
  captureScreenshot: typeof captureEditorScreenshot = captureEditorScreenshot,
263
278
  listRuntimes: typeof queryRuntimeList = queryRuntimeList,
264
279
  sendRuntimeRequest: typeof queryRuntime = queryRuntime,
280
+ captureRuntime: typeof captureRuntimeScreenshot = captureRuntimeScreenshot,
265
281
  ) {
266
282
  const [area, action] = positionals;
267
283
  const projectRoot = projectRootFlag(flags);
@@ -270,6 +286,30 @@ async function executeFileCommand(
270
286
  return listRuntimes({ projectRoot });
271
287
  }
272
288
 
289
+ if (area === 'runtime' && action === 'screenshot') {
290
+ assertAllowedFlags(flags, ['output', 'project-root', 'runtime'], 'runtime screenshot');
291
+ const output = flags.output === undefined
292
+ ? path.resolve(projectRoot, runtimeScreenshotDefaultOutput)
293
+ : path.resolve(requireFlag(flags, 'output'));
294
+ const screenshot = await captureRuntime({
295
+ projectRoot,
296
+ runtimeId: optionalFlag(flags, 'runtime'),
297
+ });
298
+ if (!screenshot.ok) return screenshot;
299
+ const capturedAt = new Date().toISOString();
300
+ fs.mkdirSync(path.dirname(output), { recursive: true });
301
+ fs.writeFileSync(output, screenshot.data);
302
+ return {
303
+ ok: true,
304
+ runtimeId: screenshot.runtimeId,
305
+ width: screenshot.width,
306
+ height: screenshot.height,
307
+ bytes: screenshot.data.byteLength,
308
+ output,
309
+ capturedAt,
310
+ };
311
+ }
312
+
273
313
  if (area === 'runtime' && action !== undefined) {
274
314
  const runtimeId = optionalFlag(flags, 'runtime');
275
315
  let request: RuntimeRequest;
@@ -371,68 +411,61 @@ async function executeFileCommand(
371
411
  }
372
412
 
373
413
  if ((area === 'build' || area === 'dev' || area === 'validate') && action === undefined) {
374
- const target = requireFlag(flags, 'target');
375
- if (target !== 'wechat') {
376
- throw new Error(`Unknown Pixifact target "${target}".`);
377
- }
414
+ assertAllowedFlags(flags, ['mode', 'project-root'], area);
415
+ const mode = flags.mode === undefined
416
+ ? area === 'dev' ? 'development' : 'production'
417
+ : requireFlag(flags, 'mode');
378
418
  if (area === 'validate') {
379
- const result = await validateWechatTarget(projectRoot);
419
+ const result = await validatePixifactTarget(projectRoot, mode);
380
420
  return result.diagnostics.length > 0
381
421
  ? {
382
422
  ok: false,
383
- target,
423
+ mode,
424
+ platform: result.platform,
384
425
  diagnostics: result.diagnostics,
385
- hint: 'Fix the listed WeChat target diagnostics, then validate again.',
426
+ hint: 'Fix the listed target diagnostics, then validate again.',
386
427
  }
387
- : { ok: true, projectRoot, target };
428
+ : { ok: true, projectRoot, mode, platform: result.platform };
388
429
  }
389
430
  if (area === 'dev') {
390
- if (flags.mode !== undefined) {
391
- throw new Error('dev --target wechat always uses development mode; remove --mode.');
392
- }
393
431
  try {
394
- const session = await devWechatTarget(projectRoot, onWechatDevEvent);
432
+ const session = await devPixifactTarget(projectRoot, mode, onDevEvent);
395
433
  return {
396
434
  ok: true,
397
435
  projectRoot,
398
- target,
399
- mode: 'development',
436
+ platform: session.platform,
437
+ mode,
400
438
  watching: true,
401
439
  report: session.initialReport,
402
440
  };
403
441
  } catch (error) {
404
- if (error instanceof WechatTargetError) {
442
+ if (error instanceof PixifactTargetError) {
405
443
  return {
406
444
  ok: false,
407
- target,
408
445
  error: error.message,
409
446
  diagnostics: error.diagnostics,
410
- hint: 'Fix the listed WeChat target diagnostics, then start dev again.',
447
+ hint: 'Fix the listed target diagnostics, then start dev again.',
411
448
  };
412
449
  }
413
450
  throw error;
414
451
  }
415
452
  }
416
- const mode = flags.mode === undefined ? 'production' : requireFlag(flags, 'mode');
417
- if (mode !== 'development' && mode !== 'production') {
418
- throw new Error('--mode must be development or production.');
419
- }
420
453
  try {
454
+ const report = await buildPixifactTarget(projectRoot, mode);
421
455
  return {
422
456
  ok: true,
423
457
  projectRoot,
424
- target,
458
+ platform: report.platform,
425
459
  mode,
426
- report: await buildWechatTarget(projectRoot, mode),
460
+ report,
427
461
  };
428
462
  } catch (error) {
429
- if (error instanceof WechatTargetError) {
463
+ if (error instanceof PixifactTargetError) {
430
464
  return {
431
465
  ok: false,
432
- target,
433
466
  error: error.message,
434
467
  diagnostics: error.diagnostics,
435
- hint: 'Fix the listed WeChat target diagnostics, then build again.',
468
+ hint: 'Fix the listed target diagnostics, then build again.',
436
469
  };
437
470
  }
438
471
  throw error;
@@ -510,9 +543,9 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
510
543
  'scene inspect --scene <scene-path>',
511
544
  'scene validate --scene <scene-path>',
512
545
  'compile-scenes',
513
- 'validate --target wechat',
514
- 'build --target wechat',
515
- 'dev --target wechat',
546
+ 'validate [--mode <vite-mode>]',
547
+ 'build [--mode <vite-mode>]',
548
+ 'dev [--mode <vite-mode>]',
516
549
  ],
517
550
  aiValidationAlternatives: [
518
551
  'scene validate --all',
@@ -520,6 +553,7 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
520
553
  runtimeCommands: [
521
554
  'runtime list',
522
555
  'runtime tree [--output <json-path>] [--runtime <runtime-id>]',
556
+ 'runtime screenshot [--output <png-path>] [--runtime <runtime-id>]',
523
557
  'runtime node <pixi-uid> [--runtime <runtime-id>]',
524
558
  'runtime state [--runtime <runtime-id>]',
525
559
  'runtime logs [--after <seq>] [--level <level>] [--runtime <runtime-id>]',
@@ -539,6 +573,7 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
539
573
  nodeLocatorSource: 'scene inspect --scene <scene-path> returns node locator values',
540
574
  defaults: {
541
575
  projectRoot: 'current working directory',
576
+ runtimeScreenshotOutput: runtimeScreenshotDefaultOutput,
542
577
  },
543
578
  }),
544
579
  stderr: '',
@@ -550,12 +585,13 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
550
585
  parsed.positionals,
551
586
  parsed.flags,
552
587
  automation,
553
- options.onWechatDevEvent,
588
+ options.onDevEvent,
554
589
  options.startEditor,
555
590
  options.readEditorContext,
556
591
  options.captureEditorScreenshot,
557
592
  options.listRuntimes,
558
593
  options.queryRuntime,
594
+ options.captureRuntimeScreenshot,
559
595
  );
560
596
  if (isFailedResult(result)) {
561
597
  return {
@@ -582,9 +618,9 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
582
618
  }
583
619
  }
584
620
 
585
- if (import.meta.url === `file://${process.argv[1]}`) {
621
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
586
622
  const result = await executePixifactCli(process.argv.slice(2), {
587
- onWechatDevEvent: (event) => process.stdout.write(jsonLine(event)),
623
+ onDevEvent: (event) => process.stdout.write(jsonLine(event)),
588
624
  });
589
625
  process.stdout.write(result.stdout);
590
626
  process.stderr.write(result.stderr);
@@ -4,6 +4,8 @@ import {
4
4
  } from 'pixifact/compiler-node';
5
5
  import type { RuntimePageDescriptor, RuntimeRequest } from 'pixifact/runtime-dev';
6
6
 
7
+ const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
8
+
7
9
  interface RuntimeSessionLookupOptions {
8
10
  projectRoot: string;
9
11
  sessionsRoot?: string;
@@ -131,3 +133,61 @@ export async function queryRuntime(options: RuntimeQueryOptions) {
131
133
  }),
132
134
  });
133
135
  }
136
+
137
+ export async function captureRuntimeScreenshot(
138
+ options: RuntimeSessionLookupOptions & { runtimeId?: string },
139
+ ): Promise<
140
+ | { ok: true; runtimeId: string; width: number; height: number; data: Uint8Array }
141
+ | { ok: false; error: string; [key: string]: unknown }
142
+ > {
143
+ const active = await activeDescriptor(options);
144
+ if (!active.ok) return active;
145
+ const fetcher = options.fetch ?? fetch;
146
+ const listed = await runtimeHostFetch(active.descriptor, 'list', fetcher);
147
+ if (listed.ok !== true || !Array.isArray(listed.runtimes)) return listed;
148
+ const selected = selectRuntime(
149
+ listed.runtimes as Array<RuntimePageDescriptor & { connectedAt: string }>,
150
+ options.runtimeId,
151
+ );
152
+ if (!selected.ok) return selected;
153
+
154
+ let response: Response;
155
+ try {
156
+ response = await fetcher(`${active.descriptor.origin}/__pixifact_runtime__/screenshot`, {
157
+ method: 'POST',
158
+ headers: {
159
+ authorization: `Bearer ${active.descriptor.token}`,
160
+ 'content-type': 'application/json',
161
+ },
162
+ body: JSON.stringify({ runtimeId: selected.runtime.runtimeId }),
163
+ });
164
+ } catch {
165
+ return {
166
+ ok: false,
167
+ error: 'Pixifact Runtime Vite server is not reachable for this project.',
168
+ };
169
+ }
170
+ if (!response.ok) {
171
+ return await response.json() as { ok: false; error: string; [key: string]: unknown };
172
+ }
173
+
174
+ const runtimeId = response.headers.get('x-pixifact-runtime');
175
+ const width = Number(response.headers.get('x-pixifact-width'));
176
+ const height = Number(response.headers.get('x-pixifact-height'));
177
+ if (
178
+ response.headers.get('content-type') !== 'image/png'
179
+ || runtimeId !== selected.runtime.runtimeId
180
+ || !Number.isInteger(width)
181
+ || width <= 0
182
+ || !Number.isInteger(height)
183
+ || height <= 0
184
+ ) {
185
+ return { ok: false, error: 'Pixifact Runtime Host returned invalid screenshot metadata.' };
186
+ }
187
+
188
+ const data = new Uint8Array(await response.arrayBuffer());
189
+ if (!Buffer.from(data.subarray(0, pngSignature.length)).equals(pngSignature)) {
190
+ return { ok: false, error: 'Pixifact Runtime Host did not return PNG screenshot data.' };
191
+ }
192
+ return { ok: true, runtimeId, width, height, data };
193
+ }
@@ -0,0 +1,323 @@
1
+ import { access, readFile, readdir, realpath, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ build,
5
+ createServer,
6
+ resolveConfig,
7
+ type Rollup,
8
+ } from 'vite';
9
+ import {
10
+ parsePixifactProjectConfig,
11
+ pixifactProjectConfigFileName,
12
+ type PixifactProjectConfig,
13
+ } from 'pixifact';
14
+ import {
15
+ compilerSceneNodeLocator,
16
+ parseSceneTemplate,
17
+ type SceneTemplateNode,
18
+ } from 'pixifact/compiler';
19
+ import {
20
+ loadPixifactEnv,
21
+ pixifactBuildReporter,
22
+ type PixifactBuildReport,
23
+ type PixifactPlatform,
24
+ } from 'pixifact/compiler-node';
25
+
26
+ export interface PixifactTargetDiagnostic {
27
+ asset?: string;
28
+ message: string;
29
+ node?: string;
30
+ scene?: string;
31
+ }
32
+
33
+ export type PixifactDevEvent = {
34
+ type: 'built';
35
+ report: PixifactBuildReport;
36
+ } | {
37
+ type: 'error';
38
+ error: string;
39
+ };
40
+
41
+ export interface PixifactDevSession {
42
+ close(): Promise<void>;
43
+ initialReport: PixifactBuildReport;
44
+ platform: PixifactPlatform;
45
+ }
46
+
47
+ export class PixifactTargetError extends Error {
48
+ constructor(
49
+ message: string,
50
+ readonly diagnostics: PixifactTargetDiagnostic[],
51
+ ) {
52
+ super(message);
53
+ this.name = 'PixifactTargetError';
54
+ }
55
+ }
56
+
57
+ export async function validatePixifactTarget(projectRootInput: string, mode: string) {
58
+ const projectRoot = await realpath(path.resolve(projectRootInput));
59
+ const { platform } = loadPixifactEnv(projectRoot, mode);
60
+ const config = parsePixifactProjectConfig(JSON.parse(await readFile(
61
+ path.join(projectRoot, pixifactProjectConfigFileName),
62
+ 'utf8',
63
+ )));
64
+ const diagnostics = await collectDiagnostics(projectRoot, config, platform);
65
+ const resolved = await resolveConfig({ root: projectRoot, mode, logLevel: 'silent' }, 'build', mode);
66
+ const pluginNames = new Set(resolved.plugins.map((plugin) => plugin.name));
67
+ for (const name of [
68
+ 'pixifact-scenes',
69
+ 'pixifact-platform',
70
+ 'pixifact-assets',
71
+ 'pixifact-resources',
72
+ 'pixifact-minigame',
73
+ ]) {
74
+ if (!pluginNames.has(name)) {
75
+ diagnostics.push({ message: `vite.config.ts must include pixifact(); missing ${name}.` });
76
+ break;
77
+ }
78
+ }
79
+ if (platform !== 'web') {
80
+ const packageName = `@pixifact/platform-${platform}`;
81
+ const resolve = resolved.createResolver();
82
+ if (!await resolve(packageName, path.join(projectRoot, 'src/main.ts'))) {
83
+ diagnostics.push({
84
+ message: `缺少 ${packageName};请运行 bun add ${packageName}。`,
85
+ });
86
+ }
87
+ }
88
+ return { config, diagnostics, mode, platform, projectRoot, resolved };
89
+ }
90
+
91
+ export async function buildPixifactTarget(
92
+ projectRootInput: string,
93
+ mode: string,
94
+ ): Promise<PixifactBuildReport> {
95
+ const validation = await validatePixifactTarget(projectRootInput, mode);
96
+ if (validation.diagnostics.length > 0) {
97
+ throw new PixifactTargetError('Pixifact target validation failed.', validation.diagnostics);
98
+ }
99
+ let report: PixifactBuildReport | undefined;
100
+ await build({
101
+ root: validation.projectRoot,
102
+ mode,
103
+ logLevel: 'silent',
104
+ plugins: [pixifactBuildReporter(validation.platform, (value) => {
105
+ report = value;
106
+ })],
107
+ });
108
+ if (!report) throw new Error('Vite completed without a Pixifact build report.');
109
+ return report;
110
+ }
111
+
112
+ export async function devPixifactTarget(
113
+ projectRootInput: string,
114
+ mode: string,
115
+ onEvent: (event: PixifactDevEvent) => void = () => undefined,
116
+ ): Promise<PixifactDevSession> {
117
+ const validation = await validatePixifactTarget(projectRootInput, mode);
118
+ if (validation.diagnostics.length > 0) {
119
+ throw new PixifactTargetError('Pixifact target validation failed.', validation.diagnostics);
120
+ }
121
+ if (validation.platform === 'web') {
122
+ const server = await createServer({
123
+ root: validation.projectRoot,
124
+ mode,
125
+ logLevel: 'silent',
126
+ });
127
+ await server.listen();
128
+ return {
129
+ close: () => server.close(),
130
+ initialReport: emptyReport('web', validation.resolved.build.outDir),
131
+ platform: 'web',
132
+ };
133
+ }
134
+ let resolveInitial: (report: PixifactBuildReport) => void;
135
+ let rejectInitial: (error: Error) => void;
136
+ const initial = new Promise<PixifactBuildReport>((resolve, reject) => {
137
+ resolveInitial = resolve;
138
+ rejectInitial = reject;
139
+ });
140
+ let first = true;
141
+ let completedReport: PixifactBuildReport | undefined;
142
+ const watcher = await build({
143
+ root: validation.projectRoot,
144
+ mode,
145
+ logLevel: 'silent',
146
+ build: { watch: {} },
147
+ plugins: [pixifactBuildReporter(validation.platform, (report) => {
148
+ completedReport = report;
149
+ })],
150
+ }) as Rollup.RollupWatcher;
151
+ watcher.on('event', (event) => {
152
+ if (event.code === 'END' && completedReport) {
153
+ const report = completedReport;
154
+ completedReport = undefined;
155
+ if (first) {
156
+ first = false;
157
+ resolveInitial(report);
158
+ } else {
159
+ onEvent({ type: 'built', report });
160
+ }
161
+ } else if (event.code === 'ERROR') {
162
+ const error = event.error instanceof Error ? event.error : new Error(String(event.error));
163
+ if (first) {
164
+ first = false;
165
+ rejectInitial(error);
166
+ } else {
167
+ onEvent({ type: 'error', error: error.message });
168
+ }
169
+ }
170
+ });
171
+ return {
172
+ close: () => watcher.close(),
173
+ initialReport: await initial,
174
+ platform: validation.platform,
175
+ };
176
+ }
177
+
178
+ async function collectDiagnostics(
179
+ projectRoot: string,
180
+ config: PixifactProjectConfig,
181
+ platform: PixifactPlatform,
182
+ ) {
183
+ const diagnostics: PixifactTargetDiagnostic[] = [];
184
+ await requireFile(projectRoot, 'src/main.ts', diagnostics, '应用入口 src/main.ts 不存在。');
185
+ if (platform !== 'web') {
186
+ await requireFile(projectRoot, `platforms/${platform}/game.json`, diagnostics, `${platform} game.json 不存在。`);
187
+ await requireFile(
188
+ projectRoot,
189
+ `platforms/${platform}/project.config.json`,
190
+ diagnostics,
191
+ `${platform} project.config.json 不存在。`,
192
+ );
193
+ }
194
+ for (const pack of config.resourcePacks ?? []) {
195
+ const root = path.join(projectRoot, 'resources', pack);
196
+ if (!await isDirectory(root)) {
197
+ diagnostics.push({ asset: `resources/${pack}`, message: `资源包 ${pack} 的目录不存在。` });
198
+ continue;
199
+ }
200
+ if (platform !== 'web') {
201
+ for (const file of await collectFiles(root)) {
202
+ if (path.extname(file).toLowerCase() === '.svg') {
203
+ diagnostics.push({
204
+ asset: projectPath(projectRoot, file),
205
+ message: `${platform} 小游戏不支持 SVG 资源。`,
206
+ });
207
+ }
208
+ }
209
+ }
210
+ }
211
+ for (const scene of Object.values(config.scenes)) {
212
+ if (!await exists(path.join(projectRoot, scene))) {
213
+ diagnostics.push({ asset: scene, message: 'Scene 文件不存在。' });
214
+ continue;
215
+ }
216
+ if (platform === 'web') continue;
217
+ const template = parseSceneTemplate(await readFile(path.join(projectRoot, scene), 'utf8'));
218
+ await collectSceneDiagnostics(template.children, scene, '', projectRoot, platform, diagnostics);
219
+ }
220
+ if (platform !== 'web') {
221
+ const gameJsonPath = path.join(projectRoot, 'platforms', platform, 'game.json');
222
+ if (await exists(gameJsonPath)) {
223
+ const gameJson = JSON.parse(await readFile(gameJsonPath, 'utf8')) as Record<string, unknown>;
224
+ const property = platform === 'wechat' ? 'subpackages' : 'subPackages';
225
+ if (gameJson[property] !== undefined) {
226
+ diagnostics.push({
227
+ asset: `platforms/${platform}/game.json`,
228
+ message: `${property} 由 Pixifact 根据 resourcePacks 生成,不能在源模板中重复声明。`,
229
+ });
230
+ }
231
+ }
232
+ }
233
+ return diagnostics;
234
+ }
235
+
236
+ async function collectSceneDiagnostics(
237
+ nodes: SceneTemplateNode[],
238
+ scene: string,
239
+ parentPath: string,
240
+ projectRoot: string,
241
+ platform: Exclude<PixifactPlatform, 'web'>,
242
+ diagnostics: PixifactTargetDiagnostic[],
243
+ ) {
244
+ for (const [index, node] of nodes.entries()) {
245
+ const nodePath = parentPath ? `${parentPath}/${index}` : String(index);
246
+ if (node.kind === 'slotOutlet') continue;
247
+ if (node.kind === 'sceneInstance') {
248
+ for (const [slotName, children] of Object.entries(node.slots)) {
249
+ await collectSceneDiagnostics(children, scene, `${nodePath}/slot:${slotName}`, projectRoot, platform, diagnostics);
250
+ }
251
+ continue;
252
+ }
253
+ const locator = compilerSceneNodeLocator(node, nodePath);
254
+ if (node.type === 'HTMLText' || node.type === 'DOMContainer') {
255
+ diagnostics.push({ message: `${platform} 小游戏不支持 ${node.type}。`, node: locator, scene });
256
+ }
257
+ if (typeof node.props.texture === 'string') {
258
+ const texture = node.props.texture;
259
+ const extension = path.extname(texture).toLowerCase();
260
+ if (!isProjectAssetPath(texture)) {
261
+ diagnostics.push({ asset: texture, message: `${platform} 小游戏 Scene 纹理必须使用项目相对路径。`, node: locator, scene });
262
+ } else if (!['.jpeg', '.jpg', '.png', '.webp'].includes(extension)) {
263
+ diagnostics.push({ asset: texture, message: `${platform} 小游戏 Scene 纹理不支持 ${extension || '无扩展名'} 格式。`, node: locator, scene });
264
+ } else if (!await exists(path.join(projectRoot, texture))) {
265
+ diagnostics.push({ asset: texture, message: `${platform} 小游戏 Scene 纹理文件不存在。`, node: locator, scene });
266
+ }
267
+ }
268
+ await collectSceneDiagnostics(node.children, scene, nodePath, projectRoot, platform, diagnostics);
269
+ }
270
+ }
271
+
272
+ function isProjectAssetPath(value: string) {
273
+ return value.trim() !== ''
274
+ && !value.startsWith('/')
275
+ && !value.startsWith('./')
276
+ && !value.includes('\\')
277
+ && !value.split('/').includes('..')
278
+ && !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
279
+ }
280
+
281
+ async function requireFile(
282
+ projectRoot: string,
283
+ relativePath: string,
284
+ diagnostics: PixifactTargetDiagnostic[],
285
+ message: string,
286
+ ) {
287
+ if (!await exists(path.join(projectRoot, relativePath))) diagnostics.push({ asset: relativePath, message });
288
+ }
289
+
290
+ async function collectFiles(root: string): Promise<string[]> {
291
+ const files: string[] = [];
292
+ for (const entry of await readdir(root, { withFileTypes: true })) {
293
+ const absolute = path.join(root, entry.name);
294
+ if (entry.isDirectory()) files.push(...await collectFiles(absolute));
295
+ else if (entry.isFile()) files.push(absolute);
296
+ }
297
+ return files;
298
+ }
299
+
300
+ async function exists(filePath: string) {
301
+ try {
302
+ await access(filePath);
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
309
+ async function isDirectory(filePath: string) {
310
+ try {
311
+ return (await stat(filePath)).isDirectory();
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ function emptyReport(platform: PixifactPlatform, outputDirectory: string): PixifactBuildReport {
318
+ return { mainPackageBytes: 0, outputDirectory, platform, subpackages: {}, totalPackageBytes: 0 };
319
+ }
320
+
321
+ function projectPath(root: string, filePath: string) {
322
+ return path.relative(root, filePath).replaceAll(path.sep, '/');
323
+ }
@@ -1,578 +0,0 @@
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
- }