pixifact 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,4 +56,4 @@ pixifact build --mode production
56
56
 
57
57
  - Bun
58
58
  - PixiJS v8
59
- - Vite 6 或更高版本
59
+ - Vite 8 或更高版本
@@ -1,6 +1,8 @@
1
1
  import { type Plugin } from 'vite';
2
2
  export type PixifactPlatform = 'web' | 'wechat' | 'douyin';
3
3
  export interface PixifactPluginOptions {
4
+ /** Code subpackage names mapped to project-relative TypeScript/JavaScript entries. */
5
+ codePackages?: Record<string, string>;
4
6
  projectRoot?: string | URL;
5
7
  }
6
8
  export interface PixifactBuildReport {
@@ -2,6 +2,7 @@ import { access, readFile, readdir, stat } from 'node:fs/promises';
2
2
  import { realpathSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import ts from 'typescript';
5
6
  import { loadEnv, } from 'vite';
6
7
  import { parsePixifactProjectConfig, pixifactProjectConfigFileName, } from '../project/index.js';
7
8
  import { compileScenes, generatedSceneAssetsFileName } from '../compiler/compileScenes.js';
@@ -26,13 +27,15 @@ const descriptors = {
26
27
  const virtualPlatformId = '\0pixifact:platform';
27
28
  const virtualAssetsId = '\0pixifact:assets';
28
29
  const virtualScenesId = '\0pixifact:scenes';
30
+ const virtualCodeLoaderId = '\0pixifact:code-loader';
29
31
  export function pixifact(options = {}) {
30
- const state = {};
32
+ const state = { codePackages: new Map() };
31
33
  return [
32
34
  scenePlugin(state, options),
33
35
  platformPlugin(state),
34
36
  assetsPlugin(state),
35
37
  resourcePlugin(state),
38
+ codePackagesPlugin(state),
36
39
  miniGamePlugin(state),
37
40
  ];
38
41
  }
@@ -45,7 +48,7 @@ function scenePlugin(state, options) {
45
48
  const platform = required(state.platform);
46
49
  const output = platform === 'web'
47
50
  ? {}
48
- : miniGameBuildConfig(projectRoot, platform, config);
51
+ : miniGameBuildConfig(projectRoot, platform, config, state.codePackages.size > 0);
49
52
  return {
50
53
  ...output,
51
54
  root: projectRoot,
@@ -130,6 +133,8 @@ function resourcePlugin(state) {
130
133
  }
131
134
  if (platform !== 'web') {
132
135
  for (const pack of localPacks(required(state.config))) {
136
+ if (state.codePackages.has(pack))
137
+ continue;
133
138
  this.emitFile({
134
139
  type: 'asset',
135
140
  fileName: path.posix.join('subpackages', pack, 'game.js'),
@@ -140,6 +145,141 @@ function resourcePlugin(state) {
140
145
  },
141
146
  };
142
147
  }
148
+ function codePackagesPlugin(state) {
149
+ return {
150
+ name: 'pixifact-code-packages',
151
+ apply: 'build',
152
+ enforce: 'post',
153
+ async buildStart(input) {
154
+ if (state.platform === 'web' || state.codePackages.size === 0)
155
+ return;
156
+ const entries = typeof input.input === 'string' ? [input.input]
157
+ : Array.isArray(input.input) ? input.input : Object.values(input.input);
158
+ const entryIds = await Promise.all(entries.map(async (entry) => (await this.resolve(entry))?.id));
159
+ for (const [name, id] of state.codePackages) {
160
+ if (entryIds.includes(id)) {
161
+ this.error(`代码分包 ${name} 不能使用应用主入口 ${id}。`);
162
+ }
163
+ this.emitFile({ type: 'chunk', id, fileName: `subpackages/${name}/game.js`, preserveSignature: 'strict' });
164
+ }
165
+ },
166
+ resolveId(id) {
167
+ return id === virtualCodeLoaderId ? id : undefined;
168
+ },
169
+ load(id) {
170
+ if (id !== virtualCodeLoaderId)
171
+ return;
172
+ const api = state.platform === 'wechat' ? 'wx' : 'tt';
173
+ return [
174
+ "import { createMiniGameSubpackageLoader } from 'pixifact/internal/minigame';",
175
+ `export const loadCodePackage = createMiniGameSubpackageLoader(() => ${api}, ${JSON.stringify(state.platform)});`,
176
+ ].join('\n');
177
+ },
178
+ async transform(code, id) {
179
+ if (state.platform === 'web' || state.codePackages.size === 0)
180
+ return;
181
+ // Native require() loads chunks. Vite's browser preload/error helper
182
+ // would otherwise access Event/window and mask module exceptions.
183
+ if (id === '\0vite/preload-helper.js') {
184
+ return 'export function __vitePreload(load) { return load(); }';
185
+ }
186
+ if (!code.includes('import'))
187
+ return;
188
+ const source = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true);
189
+ const specifiers = new Set();
190
+ function visit(node) {
191
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
192
+ const argument = node.arguments[0];
193
+ if (argument && ts.isStringLiteralLike(argument))
194
+ specifiers.add(argument.text);
195
+ }
196
+ ts.forEachChild(node, visit);
197
+ }
198
+ visit(source);
199
+ const packagesById = new Map([...state.codePackages].map(([name, entry]) => [entry, name]));
200
+ const imports = new Map();
201
+ for (const specifier of specifiers) {
202
+ const resolved = await this.resolve(specifier, id);
203
+ const name = resolved && packagesById.get(resolved.id);
204
+ if (name)
205
+ imports.set(specifier, name);
206
+ }
207
+ if (imports.size === 0)
208
+ return;
209
+ const result = ts.transpileModule(code, {
210
+ fileName: id,
211
+ compilerOptions: {
212
+ target: ts.ScriptTarget.ESNext,
213
+ module: ts.ModuleKind.ESNext,
214
+ verbatimModuleSyntax: true,
215
+ sourceMap: true,
216
+ inlineSources: true,
217
+ },
218
+ transformers: { before: [(context) => (file) => {
219
+ const factory = context.factory;
220
+ const loader = factory.createUniqueName('__pixifactLoadCodePackage');
221
+ const transform = (node) => {
222
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
223
+ const argument = node.arguments[0];
224
+ const name = argument && ts.isStringLiteralLike(argument) && imports.get(argument.text);
225
+ if (name) {
226
+ return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createCallExpression(loader, undefined, [factory.createStringLiteral(name)]), 'then'), undefined, [factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), node)]);
227
+ }
228
+ }
229
+ return ts.visitEachChild(node, transform, context);
230
+ };
231
+ const transformed = ts.visitEachChild(file, transform, context);
232
+ return factory.updateSourceFile(transformed, [
233
+ factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports([
234
+ factory.createImportSpecifier(false, factory.createIdentifier('loadCodePackage'), loader),
235
+ ])), factory.createStringLiteral(virtualCodeLoaderId)),
236
+ ...transformed.statements,
237
+ ]);
238
+ }] },
239
+ });
240
+ return { code: result.outputText, map: result.sourceMapText };
241
+ },
242
+ buildEnd(error) {
243
+ if (error || state.platform === 'web' || state.codePackages.size === 0)
244
+ return;
245
+ const packagesById = new Map([...state.codePackages].map(([name, id]) => [id, name]));
246
+ for (const root of this.getModuleIds()) {
247
+ if (!this.getModuleInfo(root)?.isEntry)
248
+ continue;
249
+ const owner = packagesById.get(root);
250
+ const visited = new Set();
251
+ const visit = (id) => {
252
+ if (visited.has(id))
253
+ return;
254
+ visited.add(id);
255
+ for (const dependency of this.getModuleInfo(id)?.importedIds ?? []) {
256
+ const name = packagesById.get(dependency);
257
+ if (name && name !== owner) {
258
+ this.error(`不能静态引用代码分包 ${name}(${id});请从主包使用 import() 加载分包入口。`);
259
+ }
260
+ visit(dependency);
261
+ }
262
+ };
263
+ visit(root);
264
+ }
265
+ },
266
+ generateBundle(_options, bundle) {
267
+ if (state.platform === 'web' || state.codePackages.size === 0)
268
+ return;
269
+ for (const chunk of Object.values(bundle)) {
270
+ if (chunk.type !== 'chunk')
271
+ continue;
272
+ const owner = chunk.fileName.match(/^subpackages\/([^/]+)\//)?.[1];
273
+ for (const dependency of [...chunk.imports, ...chunk.dynamicImports]) {
274
+ const name = dependency.match(/^subpackages\/([^/]+)\//)?.[1];
275
+ if (name && owner && name !== owner) {
276
+ this.error(`代码分包 ${owner} 不能引用分包 ${name};请由主包发起分包加载。`);
277
+ }
278
+ }
279
+ }
280
+ },
281
+ };
282
+ }
143
283
  function miniGamePlugin(state) {
144
284
  return {
145
285
  name: 'pixifact-minigame',
@@ -154,9 +294,11 @@ function miniGamePlugin(state) {
154
294
  const gameJson = JSON.parse(await readFile(path.join(configDir, 'game.json'), 'utf8'));
155
295
  const projectConfig = JSON.parse(await readFile(path.join(configDir, 'project.config.json'), 'utf8'));
156
296
  if (gameJson[descriptor.subpackageProperty] !== undefined) {
157
- throw new Error(`platforms/${platform}/game.json must not declare ${descriptor.subpackageProperty}; Pixifact manages resource subpackages.`);
297
+ throw new Error(`platforms/${platform}/game.json 的 ${descriptor.subpackageProperty} 由 Pixifact 根据资源包和代码分包生成,不能在源模板中重复声明。`);
158
298
  }
159
- gameJson[descriptor.subpackageProperty] = localPacks(required(state.config)).map((name) => ({
299
+ gameJson[descriptor.subpackageProperty] = [...new Set([
300
+ ...localPacks(required(state.config)), ...state.codePackages.keys(),
301
+ ])].map((name) => ({
160
302
  name,
161
303
  root: `subpackages/${name}`,
162
304
  }));
@@ -212,8 +354,23 @@ async function configureState(state, options, config, env) {
212
354
  state.env = loaded.env;
213
355
  state.platform = loaded.platform;
214
356
  state.config = parsePixifactProjectConfig(JSON.parse(await readFile(path.join(projectRoot, pixifactProjectConfigFileName), 'utf8')));
357
+ state.codePackages = new Map();
358
+ for (const [name, entry] of Object.entries(options.codePackages ?? {})) {
359
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) {
360
+ throw new Error(`代码分包名称 ${name} 只能包含字母、数字、连字符和下划线,并以字母或数字开头。`);
361
+ }
362
+ const absolute = realpathSync(path.resolve(projectRoot, entry)).replaceAll(path.sep, '/');
363
+ const relative = path.relative(projectRoot, absolute);
364
+ if (path.isAbsolute(entry) || relative.startsWith(`..${path.sep}`) || relative === '..') {
365
+ throw new Error(`代码分包 ${name} 的入口必须位于项目内并使用项目相对路径。`);
366
+ }
367
+ if ([...state.codePackages.values()].includes(absolute)) {
368
+ throw new Error(`代码分包 ${name} 重复声明入口 ${entry}。`);
369
+ }
370
+ state.codePackages.set(name, absolute);
371
+ }
215
372
  }
216
- function miniGameBuildConfig(projectRoot, platform, config) {
373
+ function miniGameBuildConfig(projectRoot, platform, config, hasCodePackages) {
217
374
  const descriptor = descriptors[platform];
218
375
  return {
219
376
  appType: 'custom',
@@ -227,6 +384,15 @@ function miniGameBuildConfig(projectRoot, platform, config) {
227
384
  modulePreload: false,
228
385
  rollupOptions: {
229
386
  ...config.build?.rollupOptions,
387
+ ...(hasCodePackages ? {
388
+ preserveEntrySignatures: 'strict',
389
+ // Rolldown's cross-chunk constant inlining crashes when Scene
390
+ // registration and Pixi's async Application imports share CJS chunks.
391
+ optimization: {
392
+ ...config.build?.rollupOptions?.optimization,
393
+ inlineConst: false,
394
+ },
395
+ } : {}),
230
396
  input: config.build?.rollupOptions?.input ?? path.join(projectRoot, 'src/main.ts'),
231
397
  output: {
232
398
  ...(Array.isArray(config.build?.rollupOptions?.output)
@@ -237,7 +403,12 @@ function miniGameBuildConfig(projectRoot, platform, config) {
237
403
  `var navigator=globalThis.navigator||(globalThis.navigator={gpu:null,userAgent:${JSON.stringify(descriptor.userAgent)}});`,
238
404
  ].join('\n'),
239
405
  entryFileNames: 'game.js',
240
- format: 'iife',
406
+ format: hasCodePackages ? 'cjs' : 'iife',
407
+ ...(hasCodePackages ? {
408
+ chunkFileNames: 'chunks/[name]-[hash].js',
409
+ codeSplitting: true,
410
+ dynamicImportInCjs: false,
411
+ } : {}),
241
412
  },
242
413
  },
243
414
  target: descriptor.ecmascriptTarget,
@@ -39,12 +39,19 @@ export async function createMiniGameApplication(api, userAgent, fetchResource, c
39
39
  const lifecycle = bindMiniGameLifecycle(api, app.ticker, {
40
40
  onHide: () => input.cancelAll(),
41
41
  });
42
+ const resize = (size) => {
43
+ runtime.width = size.windowWidth;
44
+ runtime.height = size.windowHeight;
45
+ app.renderer.resize(runtime.width, runtime.height);
46
+ };
47
+ api.onWindowResize?.(resize);
42
48
  let destroyed = false;
43
49
  const destroy = app.destroy.bind(app);
44
50
  app.destroy = ((...args) => {
45
51
  if (destroyed)
46
52
  return;
47
53
  destroyed = true;
54
+ api.offWindowResize?.(resize);
48
55
  lifecycle.dispose();
49
56
  input.dispose();
50
57
  destroy(...args);
@@ -1,2 +1,4 @@
1
1
  import type { MiniGameApi, MiniGameFetch } from './types.js';
2
+ export declare function decodeUtf8(buffer: ArrayBuffer): string;
3
+ export declare function encodeUtf8(value: string): ArrayBuffer;
2
4
  export declare function fetchMiniGameResource(api: MiniGameApi, input: RequestInfo | URL, init: RequestInit | undefined, platformName: string): ReturnType<MiniGameFetch>;
@@ -7,7 +7,7 @@ class MiniGameResponseHeaders {
7
7
  return this.values.get(name.toLowerCase()) ?? null;
8
8
  }
9
9
  }
10
- function decodeUtf8(buffer) {
10
+ export function decodeUtf8(buffer) {
11
11
  const bytes = new Uint8Array(buffer);
12
12
  let output = '';
13
13
  for (let index = 0; index < bytes.length;) {
@@ -34,7 +34,7 @@ function decodeUtf8(buffer) {
34
34
  }
35
35
  return output;
36
36
  }
37
- function encodeUtf8(value) {
37
+ export function encodeUtf8(value) {
38
38
  const bytes = [];
39
39
  for (const character of value) {
40
40
  const codePoint = character.codePointAt(0);
@@ -85,19 +85,19 @@ function installEventTarget(target, nativeTypes = new Set()) {
85
85
  return !event.defaultPrevented;
86
86
  };
87
87
  }
88
- function installCanvasSurface(canvas, width, height) {
88
+ function installCanvasSurface(canvas, viewport) {
89
89
  installEventTarget(canvas, new Set(['webglcontextlost', 'webglcontextrestored']));
90
90
  canvas.style ??= {};
91
91
  Object.defineProperty(canvas, 'isConnected', { configurable: true, value: true });
92
92
  Object.defineProperty(canvas, 'getBoundingClientRect', {
93
93
  configurable: true,
94
94
  value: () => ({
95
- bottom: height,
96
- height,
95
+ bottom: viewport.height,
96
+ height: viewport.height,
97
97
  left: 0,
98
- right: width,
98
+ right: viewport.width,
99
99
  top: 0,
100
- width,
100
+ width: viewport.width,
101
101
  x: 0,
102
102
  y: 0,
103
103
  }),
@@ -176,8 +176,9 @@ export function createMiniGameRuntime(api, userAgent) {
176
176
  const width = Math.max(1, Math.round(Number(info.windowWidth ?? info.screenWidth)));
177
177
  const height = Math.max(1, Math.round(Number(info.windowHeight ?? info.screenHeight)));
178
178
  const resolution = Math.min(2, Math.max(1, Number(info.pixelRatio ?? 1)));
179
- installCanvasSurface(canvas, width, height);
179
+ const viewport = { width, height };
180
+ installCanvasSurface(canvas, viewport);
180
181
  installGlobals(api, canvas, userAgent);
181
182
  const { context, webGLVersion } = createContext(canvas);
182
- return { canvas, context, height, resolution, userAgent, webGLVersion, width };
183
+ return Object.assign(viewport, { canvas, context, resolution, userAgent, webGLVersion });
183
184
  }
@@ -1,3 +1,4 @@
1
+ const subpackageStates = new WeakMap();
1
2
  export function miniGameResourceSubpackage(input) {
2
3
  const url = typeof input === 'string' ? input : (input.url ?? String(input));
3
4
  if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url))
@@ -7,9 +8,14 @@ export function miniGameResourceSubpackage(input) {
7
8
  }
8
9
  export function createMiniGameSubpackageLoader(apiOrFactory, platformName) {
9
10
  const getApi = typeof apiOrFactory === 'function' ? apiOrFactory : () => apiOrFactory;
10
- const loaded = new Set();
11
- const loading = new Map();
12
11
  return function loadSubpackage(name) {
12
+ const api = getApi();
13
+ let state = subpackageStates.get(api);
14
+ if (!state) {
15
+ state = { loaded: new Set(), loading: new Map() };
16
+ subpackageStates.set(api, state);
17
+ }
18
+ const { loaded, loading } = state;
13
19
  if (loaded.has(name)) {
14
20
  return Promise.resolve();
15
21
  }
@@ -18,7 +24,7 @@ export function createMiniGameSubpackageLoader(apiOrFactory, platformName) {
18
24
  return existing;
19
25
  }
20
26
  const pending = new Promise((resolve, reject) => {
21
- getApi().loadSubpackage({
27
+ api.loadSubpackage({
22
28
  name,
23
29
  fail: (error) => reject(new Error(`Failed to load ${platformName} subpackage ${name}: ${error.errMsg}`)),
24
30
  success: resolve,
@@ -96,6 +96,14 @@ export interface MiniGameApi {
96
96
  offHide?(callback: () => void): void;
97
97
  onShow(callback: () => void): void;
98
98
  offShow?(callback: () => void): void;
99
+ onWindowResize?(callback: (size: {
100
+ windowWidth: number;
101
+ windowHeight: number;
102
+ }) => void): void;
103
+ offWindowResize?(callback: (size: {
104
+ windowWidth: number;
105
+ windowHeight: number;
106
+ }) => void): void;
99
107
  onTouchStart(callback: (event: MiniGameTouchEvent) => void): void;
100
108
  offTouchStart?(callback: (event: MiniGameTouchEvent) => void): void;
101
109
  onTouchMove(callback: (event: MiniGameTouchEvent) => void): void;
@@ -1,7 +1,9 @@
1
1
  import { type Application } from 'pixi.js';
2
- import { type RuntimeJsonValue, type RuntimeRequest } from './protocol.js';
2
+ import { type RuntimeJsonValue, type RuntimeRequest, type RuntimeScreenshotResult } from './protocol.js';
3
3
  export interface RegisterPixiRuntimeOptions {
4
4
  getState?: () => RuntimeJsonValue;
5
+ /** 为共享 Canvas 的渲染插件提供完整合成截图。 */
6
+ captureScreenshot?: () => Pick<RuntimeScreenshotResult, 'width' | 'height' | 'dataUrl'> | Promise<Pick<RuntimeScreenshotResult, 'width' | 'height' | 'dataUrl'>>;
5
7
  }
6
8
  export interface PixifactRuntimeHotContext {
7
9
  on(event: string, listener: (data: unknown) => void): void;
@@ -299,6 +299,7 @@ export function createPixifactRuntimeClient(options) {
299
299
  const logs = createRuntimeLogCapture(options.console, options.window, now);
300
300
  let app;
301
301
  let getState;
302
+ let captureComposite;
302
303
  function descriptor() {
303
304
  return {
304
305
  runtimeId: options.runtimeId,
@@ -316,6 +317,8 @@ export function createPixifactRuntimeClient(options) {
316
317
  return app;
317
318
  }
318
319
  async function captureScreenshot(currentApp) {
320
+ if (captureComposite)
321
+ return { ...await captureComposite(), runtimeId: options.runtimeId };
319
322
  const width = Math.max(1, Math.floor(currentApp.screen.width));
320
323
  const height = Math.max(1, Math.floor(currentApp.screen.height));
321
324
  const target = RenderTexture.create({ width, height, resolution: 1 });
@@ -412,6 +415,7 @@ export function createPixifactRuntimeClient(options) {
412
415
  throw new Error('This page already has a registered PixiJS Application.');
413
416
  app = application;
414
417
  getState = registerOptions.getState;
418
+ captureComposite = registerOptions.captureScreenshot;
415
419
  announce();
416
420
  },
417
421
  handleRequest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixifact",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Pixifact 2D UI 和轻量游戏 Scene 语义框架。",
5
5
  "packageManager": "bun@1.3.13",
6
6
  "main": "./dist/index.js",
@@ -78,7 +78,7 @@
78
78
  },
79
79
  "peerDependencies": {
80
80
  "pixi.js": ">=8.3.0",
81
- "vite": ">=6.0.0"
81
+ "vite": ">=8.0.0"
82
82
  },
83
83
  "dependencies": {
84
84
  "typescript": "^5.3.3"