pixifact-cli 0.6.1 → 0.7.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 +21 -0
- package/README.md +22 -9
- package/package.json +3 -3
- package/src/automation.ts +1 -1
- package/src/editorServer.ts +6 -1
- package/src/pixifact-cli.ts +45 -40
- package/src/viteTarget.ts +323 -0
- package/src/wechatTarget.ts +0 -578
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
|
@@ -82,22 +82,35 @@ bun run build
|
|
|
82
82
|
|
|
83
83
|
不要编辑 `.pixifact/generated/**` 下的生成文件。
|
|
84
84
|
|
|
85
|
-
##
|
|
85
|
+
## Web / 微信 / 抖音统一构建
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
三个平台共享 `src/main.ts` 和 Vite 配置。平台由标准 Vite mode env 文件选择,例如 `.env.wechat`:
|
|
88
|
+
|
|
89
|
+
```ini
|
|
90
|
+
VITE_PLATFORM=wechat
|
|
91
|
+
VITE_APP_ID=wx123456
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
微信项目安装 `@pixifact/platform-wechat` 后运行:
|
|
88
95
|
|
|
89
96
|
```bash
|
|
90
|
-
pixifact validate --
|
|
91
|
-
pixifact build --
|
|
92
|
-
pixifact
|
|
93
|
-
|
|
97
|
+
pixifact validate --mode wechat
|
|
98
|
+
pixifact build --mode wechat
|
|
99
|
+
pixifact dev --mode wechat
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
抖音项目安装 `@pixifact/platform-douyin` 后运行:
|
|
103
|
+
|
|
104
|
+
pixifact validate --mode douyin
|
|
105
|
+
pixifact build --mode douyin
|
|
106
|
+
pixifact dev --mode douyin
|
|
94
107
|
```
|
|
95
108
|
|
|
96
|
-
`
|
|
109
|
+
Web mode 只需要 `pixifact`,不安装小游戏平台包也能构建。`dev` 默认 mode 为 `development`,`build` 和 `validate` 默认为 `production`;显式 `--mode` 原样交给 Vite。默认产物目录为 `dist/<platform>/`,需要修改时使用 Vite `build.outDir`。
|
|
97
110
|
|
|
98
|
-
|
|
111
|
+
CLI 使用 Vite 完成 env、TypeScript、tree-shaking、watch、静态资源和产物生命周期;Pixifact 插件负责 Scene 编译、当前平台虚拟模块、Pixi manifest、资源分包、原生配置和包体检查。业务代码只使用 PixiJS `Assets`,不需要手动加载分包。
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
完整配置见 [微信小游戏构建](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
114
|
|
|
102
115
|
## 环境要求
|
|
103
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixifact-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
-
"
|
|
36
|
-
"
|
|
35
|
+
"pixifact": "^0.7.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,
|
package/src/editorServer.ts
CHANGED
|
@@ -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
|
|
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(() => {
|
package/src/pixifact-cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
@@ -10,19 +11,19 @@ import { captureEditorScreenshot, queryEditorContext } from './editorSession';
|
|
|
10
11
|
import { queryRuntime, queryRuntimeList } from './runtimeSession';
|
|
11
12
|
import type { RuntimeLogLevel, RuntimeRequest } from 'pixifact/runtime-dev';
|
|
12
13
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
type
|
|
17
|
-
|
|
18
|
-
} from './
|
|
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
|
-
|
|
26
|
+
onDevEvent?: (event: PixifactDevEvent) => void;
|
|
26
27
|
readEditorContext?: typeof queryEditorContext;
|
|
27
28
|
listRuntimes?: typeof queryRuntimeList;
|
|
28
29
|
queryRuntime?: typeof queryRuntime;
|
|
@@ -94,6 +95,17 @@ function optionalFlag(flags: Record<string, string | true>, name: string) {
|
|
|
94
95
|
return value === undefined ? undefined : requireFlag(flags, name);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
function assertAllowedFlags(
|
|
99
|
+
flags: Record<string, string | true>,
|
|
100
|
+
allowed: readonly string[],
|
|
101
|
+
command: string,
|
|
102
|
+
) {
|
|
103
|
+
const unexpected = Object.keys(flags).find((name) => !allowed.includes(name));
|
|
104
|
+
if (unexpected !== undefined) {
|
|
105
|
+
throw new Error(`Unknown option "--${unexpected}" for Pixifact ${command}.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
97
109
|
function finiteNumberFlag(flags: Record<string, string | true>, name: string) {
|
|
98
110
|
const value = Number(requireFlag(flags, name));
|
|
99
111
|
if (!Number.isFinite(value)) throw new Error(`--${name} must be a finite number.`);
|
|
@@ -256,7 +268,7 @@ async function executeFileCommand(
|
|
|
256
268
|
positionals: string[],
|
|
257
269
|
flags: Record<string, string | true>,
|
|
258
270
|
automation: Automation,
|
|
259
|
-
|
|
271
|
+
onDevEvent?: (event: PixifactDevEvent) => void,
|
|
260
272
|
startEditor: typeof startEditorServer = startEditorServer,
|
|
261
273
|
readEditorContext: typeof queryEditorContext = queryEditorContext,
|
|
262
274
|
captureScreenshot: typeof captureEditorScreenshot = captureEditorScreenshot,
|
|
@@ -371,68 +383,61 @@ async function executeFileCommand(
|
|
|
371
383
|
}
|
|
372
384
|
|
|
373
385
|
if ((area === 'build' || area === 'dev' || area === 'validate') && action === undefined) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
386
|
+
assertAllowedFlags(flags, ['mode', 'project-root'], area);
|
|
387
|
+
const mode = flags.mode === undefined
|
|
388
|
+
? area === 'dev' ? 'development' : 'production'
|
|
389
|
+
: requireFlag(flags, 'mode');
|
|
378
390
|
if (area === 'validate') {
|
|
379
|
-
const result = await
|
|
391
|
+
const result = await validatePixifactTarget(projectRoot, mode);
|
|
380
392
|
return result.diagnostics.length > 0
|
|
381
393
|
? {
|
|
382
394
|
ok: false,
|
|
383
|
-
|
|
395
|
+
mode,
|
|
396
|
+
platform: result.platform,
|
|
384
397
|
diagnostics: result.diagnostics,
|
|
385
|
-
hint: 'Fix the listed
|
|
398
|
+
hint: 'Fix the listed target diagnostics, then validate again.',
|
|
386
399
|
}
|
|
387
|
-
: { ok: true, projectRoot,
|
|
400
|
+
: { ok: true, projectRoot, mode, platform: result.platform };
|
|
388
401
|
}
|
|
389
402
|
if (area === 'dev') {
|
|
390
|
-
if (flags.mode !== undefined) {
|
|
391
|
-
throw new Error('dev --target wechat always uses development mode; remove --mode.');
|
|
392
|
-
}
|
|
393
403
|
try {
|
|
394
|
-
const session = await
|
|
404
|
+
const session = await devPixifactTarget(projectRoot, mode, onDevEvent);
|
|
395
405
|
return {
|
|
396
406
|
ok: true,
|
|
397
407
|
projectRoot,
|
|
398
|
-
|
|
399
|
-
mode
|
|
408
|
+
platform: session.platform,
|
|
409
|
+
mode,
|
|
400
410
|
watching: true,
|
|
401
411
|
report: session.initialReport,
|
|
402
412
|
};
|
|
403
413
|
} catch (error) {
|
|
404
|
-
if (error instanceof
|
|
414
|
+
if (error instanceof PixifactTargetError) {
|
|
405
415
|
return {
|
|
406
416
|
ok: false,
|
|
407
|
-
target,
|
|
408
417
|
error: error.message,
|
|
409
418
|
diagnostics: error.diagnostics,
|
|
410
|
-
hint: 'Fix the listed
|
|
419
|
+
hint: 'Fix the listed target diagnostics, then start dev again.',
|
|
411
420
|
};
|
|
412
421
|
}
|
|
413
422
|
throw error;
|
|
414
423
|
}
|
|
415
424
|
}
|
|
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
425
|
try {
|
|
426
|
+
const report = await buildPixifactTarget(projectRoot, mode);
|
|
421
427
|
return {
|
|
422
428
|
ok: true,
|
|
423
429
|
projectRoot,
|
|
424
|
-
|
|
430
|
+
platform: report.platform,
|
|
425
431
|
mode,
|
|
426
|
-
report
|
|
432
|
+
report,
|
|
427
433
|
};
|
|
428
434
|
} catch (error) {
|
|
429
|
-
if (error instanceof
|
|
435
|
+
if (error instanceof PixifactTargetError) {
|
|
430
436
|
return {
|
|
431
437
|
ok: false,
|
|
432
|
-
target,
|
|
433
438
|
error: error.message,
|
|
434
439
|
diagnostics: error.diagnostics,
|
|
435
|
-
hint: 'Fix the listed
|
|
440
|
+
hint: 'Fix the listed target diagnostics, then build again.',
|
|
436
441
|
};
|
|
437
442
|
}
|
|
438
443
|
throw error;
|
|
@@ -510,9 +515,9 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
|
|
|
510
515
|
'scene inspect --scene <scene-path>',
|
|
511
516
|
'scene validate --scene <scene-path>',
|
|
512
517
|
'compile-scenes',
|
|
513
|
-
'validate --
|
|
514
|
-
'build --
|
|
515
|
-
'dev --
|
|
518
|
+
'validate [--mode <vite-mode>]',
|
|
519
|
+
'build [--mode <vite-mode>]',
|
|
520
|
+
'dev [--mode <vite-mode>]',
|
|
516
521
|
],
|
|
517
522
|
aiValidationAlternatives: [
|
|
518
523
|
'scene validate --all',
|
|
@@ -550,7 +555,7 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
|
|
|
550
555
|
parsed.positionals,
|
|
551
556
|
parsed.flags,
|
|
552
557
|
automation,
|
|
553
|
-
options.
|
|
558
|
+
options.onDevEvent,
|
|
554
559
|
options.startEditor,
|
|
555
560
|
options.readEditorContext,
|
|
556
561
|
options.captureEditorScreenshot,
|
|
@@ -582,9 +587,9 @@ export async function executePixifactCli(argv: string[], options: CliOptions = {
|
|
|
582
587
|
}
|
|
583
588
|
}
|
|
584
589
|
|
|
585
|
-
if (import.meta.url ===
|
|
590
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
586
591
|
const result = await executePixifactCli(process.argv.slice(2), {
|
|
587
|
-
|
|
592
|
+
onDevEvent: (event) => process.stdout.write(jsonLine(event)),
|
|
588
593
|
});
|
|
589
594
|
process.stdout.write(result.stdout);
|
|
590
595
|
process.stderr.write(result.stderr);
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { access, readFile, readdir, 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 = 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
|
+
}
|
package/src/wechatTarget.ts
DELETED
|
@@ -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
|
-
}
|