miaoda-game-devkit 0.2.9 → 0.2.11
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 +27 -1
- package/dist/cli/lint.js +32 -1
- package/dist/game-clock-suUidZdT.d.mts +11 -0
- package/dist/game-clock-suUidZdT.d.ts +11 -0
- package/dist/{gameplay-audit-CCJo4Qhk.d.mts → gameplay-audit-DmbBA0M_.d.mts} +7 -1
- package/dist/{gameplay-audit-CCJo4Qhk.d.ts → gameplay-audit-DmbBA0M_.d.ts} +7 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +165 -4
- package/dist/index.mjs +165 -4
- package/dist/lint/game-telemetry.contract.mjs +165 -4
- package/dist/lint/gameplay-contract.contract.mjs +165 -4
- package/dist/lint/phaser-headless.contract.mjs +281 -4
- package/dist/lint/vitest-config.contract.mjs +234 -11
- package/dist/phaser-facade.mjs +156 -0
- package/dist/react/index.d.mts +13 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +57 -0
- package/dist/react/index.mjs +29 -0
- package/dist/react/testing.d.mts +21 -0
- package/dist/react/testing.d.ts +21 -0
- package/dist/react/testing.js +82 -0
- package/dist/react/testing.mjs +57 -0
- package/dist/react/vitest-config.d.mts +14 -0
- package/dist/react/vitest-config.d.ts +14 -0
- package/dist/react/vitest-config.js +57 -0
- package/dist/react/vitest-config.mjs +32 -0
- package/dist/react/vitest-setup.d.mts +2 -0
- package/dist/react/vitest-setup.d.ts +2 -0
- package/dist/react/vitest-setup.js +35 -0
- package/dist/react/vitest-setup.mjs +33 -0
- package/dist/vite.d.mts +22 -0
- package/dist/vite.d.ts +22 -0
- package/dist/vite.js +93 -0
- package/dist/vite.mjs +67 -0
- package/dist/vitest-config.d.mts +2 -2
- package/dist/vitest-config.d.ts +2 -2
- package/dist/vitest-config.js +53 -3
- package/dist/vitest-config.mjs +53 -3
- package/package.json +72 -2
package/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# miaoda-game-devkit
|
|
2
2
|
|
|
3
|
-
妙搭 Phaser 4 游戏模板的共享开发基础设施。
|
|
3
|
+
妙搭 Phaser 4 与 React 游戏模板的共享开发基础设施。
|
|
4
4
|
|
|
5
5
|
该开发工具包提供:
|
|
6
6
|
|
|
7
|
+
- React 游戏的生产/手动时钟、Strict Mode 安全 Session ownership、JSDOM
|
|
8
|
+
Vitest 配置和自动收集运行错误的 Testing Library setup,分别从 `miaoda-game-devkit/react`、
|
|
9
|
+
`miaoda-game-devkit/react/testing` 和 `miaoda-game-devkit/react/vitest-config` 导入;
|
|
10
|
+
|
|
7
11
|
- 支持 DOM 鼠标、键盘和触摸输入的确定性 Phaser 无头测试宿主;
|
|
8
12
|
- 不依赖渲染器的运行时、交互区域和文字健康检查;
|
|
9
13
|
- 由模板平台维护的通用 Vitest 契约测试;
|
|
@@ -11,6 +15,8 @@
|
|
|
11
15
|
Oxlint 和 Phaser 通用契约检查。
|
|
12
16
|
- 统一的 `defineGameVitestConfig` 配置工厂,固定 Phaser 无头测试依赖的基础配置,
|
|
13
17
|
同时允许每个游戏声明 Scene 审计范围,并配置额外 alias、setup 文件和超时。
|
|
18
|
+
- 与 Vite `defineConfig` 同签名的 `defineGameViteConfig` 配置工厂,统一维护生产 Scene
|
|
19
|
+
的运行时基础能力,同时完整保留游戏自己的 Vite 配置。
|
|
14
20
|
|
|
15
21
|
互动游戏的长流程优先使用游戏自己暴露的 `globalThis.gameTelemetry`。稳定 façade 通常调用
|
|
16
22
|
`installGameTelemetry(this.game, telemetry)` 绑定 Game 生命周期;只有刻意局部的 façade 才绑定
|
|
@@ -24,6 +30,12 @@ Scene 命令、restart、业务 checkpoint 和销毁。玩法测试可以调用
|
|
|
24
30
|
`host.assertGameplayEvidence()` 把这些客观事实设为门禁;它不推断业务语义,
|
|
25
31
|
authoritative state 仍必须由测试显式断言。
|
|
26
32
|
|
|
33
|
+
HEADLESS host 会把 Phaser 自身的 warning/error、Loader 文件失败、漏掉 `load.start()` 的队列、
|
|
34
|
+
未注册 Scene 命令和可见对象的 `__MISSING` 纹理升级为测试失败。只有经过确认的引擎
|
|
35
|
+
warning 才能在创建 host 时通过 `ignoreEngineWarnings: [/pattern/]` 显式豁免;资源 URL
|
|
36
|
+
仍须通过浏览器验证,因为 JSDOM 图片加载只支持测试用的 `data:image/` URL。刻意暂存
|
|
37
|
+
资源供稍后加载的 Scene 可以通过 `allowIdleLoaderQueues: ["SceneKey"]` 精确声明。
|
|
38
|
+
|
|
27
39
|
玩法测试应优先使用 `gameplayTest`。它是玩法 contract 的唯一权威声明,在执行阶段绑定
|
|
28
40
|
HEADLESS host,并在 `onTestFinished` 校验 Ledger。`vitest.config.ts` 默认只列生产 Scene,
|
|
29
41
|
不再复制 contract id、测试文件和证据要求。Reporter 从测试 metadata 汇总交互项目的最低
|
|
@@ -54,6 +66,20 @@ monorepo 中的模板可以放置只包含 `extends: ["miaoda-game-devkit/biome"
|
|
|
54
66
|
别名等项目差异。TypeScript 严格模式、bundler 模块解析和无输出检查等平台基线由开发
|
|
55
67
|
工具包集中维护,`miaoda-game-lint` 会直接检查模板的这个配置。
|
|
56
68
|
|
|
69
|
+
Phaser4 模板的 `vite.config.ts` 应从 `miaoda-game-devkit/vite` 导入 `defineGameViteConfig`,调用方式
|
|
70
|
+
与 Vite 的 `defineConfig` 相同,支持配置对象、Promise、同步配置函数和异步配置函数:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
export default defineGameViteConfig({
|
|
74
|
+
plugins: [miaodaDevPlugin()],
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
该入口只增强 `src/scenes` 中对 Phaser 的导入。游戏 Scene 仍按标准方式继承
|
|
79
|
+
`Phaser.Scene`;devkit 在 Scene 创建前提供 RexUI,并在创建后验证业务确实完成了 RexUI
|
|
80
|
+
根布局。RexUI 的加载、Phaser 4.0 兼容和 Scene Plugin 生命周期属于 devkit 责任,游戏
|
|
81
|
+
配置不重复注册。
|
|
82
|
+
|
|
57
83
|
具体游戏的玩法测试仍由模板使用方放在 `tests/`。运行时测试从本开发工具包
|
|
58
84
|
导入 `createHeadlessGame`;有限目标游戏通过合法 Telemetry controls 走到正常成功终局,
|
|
59
85
|
无尽或沙盒游戏证明有意义的持续进展。短输入测试从入口操作走到一个主要操作,用真实 DOM
|
package/dist/cli/lint.js
CHANGED
|
@@ -49,10 +49,41 @@ async function runContracts() {
|
|
|
49
49
|
["run", "--config", (0, import_node_path.join)(devkitRoot, "dist/lint/contracts.config.mjs")]
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
|
+
function checkGameViteConfig() {
|
|
53
|
+
const viteConfigPath = (0, import_node_path.join)(projectRoot, "vite.config.ts");
|
|
54
|
+
if (!(0, import_node_fs.existsSync)(viteConfigPath)) {
|
|
55
|
+
console.error(
|
|
56
|
+
"[vite-config] \u7F3A\u5C11\u6A21\u677F\u5FC5\u9700\u7684 vite.config.ts\u3002\u8BF7\u6062\u590D\u6A21\u677F\u539F\u6587\u4EF6\u540E\u91CD\u65B0\u8FD0\u884C pnpm lint\u3002"
|
|
57
|
+
);
|
|
58
|
+
return { name: "vite-config", ok: false };
|
|
59
|
+
}
|
|
60
|
+
const source = (0, import_node_fs.readFileSync)(viteConfigPath, "utf8");
|
|
61
|
+
const hasDevkitImport = /from\s+["']miaoda-game-devkit\/vite["']/.test(source);
|
|
62
|
+
const hasConfigCall = /defineGameViteConfig\s*\(/.test(source);
|
|
63
|
+
if (!hasDevkitImport || !hasConfigCall) {
|
|
64
|
+
console.error(
|
|
65
|
+
"[vite-config] vite.config.ts \u672A\u4F7F\u7528\u6A21\u677F\u89C4\u5B9A\u7684\u914D\u7F6E\u5165\u53E3\u3002\n\u8BF7\u4ECE miaoda-game-devkit/vite \u5BFC\u5165 defineGameViteConfig\uFF0C\u5E76\u7528 defineGameViteConfig({ ... }) \u5305\u4F4F\u5F53\u524D\u7684\u914D\u7F6E\u5BF9\u8C61\u3002\n\u53EA\u66FF\u6362\u914D\u7F6E\u51FD\u6570\uFF0C\u4FDD\u7559\u73B0\u6709 plugins\u3001resolve \u7B49\u5BF9\u8C61\u5185\u5BB9\u4E0D\u53D8\u3002"
|
|
66
|
+
);
|
|
67
|
+
return { name: "vite-config", ok: false };
|
|
68
|
+
}
|
|
69
|
+
return { name: "vite-config", ok: true };
|
|
70
|
+
}
|
|
71
|
+
function projectUsesPhaser4() {
|
|
72
|
+
const manifestPath = (0, import_node_path.join)(projectRoot, "package.json");
|
|
73
|
+
if (!(0, import_node_fs.existsSync)(manifestPath)) return false;
|
|
74
|
+
try {
|
|
75
|
+
const manifest = JSON.parse((0, import_node_fs.readFileSync)(manifestPath, "utf8"));
|
|
76
|
+
const version = manifest.dependencies?.phaser ?? manifest.devDependencies?.phaser;
|
|
77
|
+
return typeof version === "string" && /(?:^|[^0-9])4(?:\.|$)/.test(version);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
52
82
|
async function runAllChecks() {
|
|
53
83
|
if (contractsOnly) {
|
|
54
84
|
return [await runContracts()];
|
|
55
85
|
}
|
|
86
|
+
const viteConfig = projectUsesPhaser4() ? [checkGameViteConfig()] : [];
|
|
56
87
|
const [tsgo, biome] = await Promise.all([
|
|
57
88
|
run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
|
|
58
89
|
run("biome", "@biomejs/biome", "biome", [
|
|
@@ -76,7 +107,7 @@ async function runAllChecks() {
|
|
|
76
107
|
"src"
|
|
77
108
|
]);
|
|
78
109
|
const contracts = await runContracts();
|
|
79
|
-
return [tsgo, biome, tailwind, oxlint, contracts];
|
|
110
|
+
return [...viteConfig, tsgo, biome, tailwind, oxlint, contracts];
|
|
80
111
|
}
|
|
81
112
|
async function main() {
|
|
82
113
|
const results = await runAllChecks();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type GameFrameCallback = (timestamp: number) => void;
|
|
2
|
+
interface GameClock {
|
|
3
|
+
now(): number;
|
|
4
|
+
requestFrame(callback: GameFrameCallback): number;
|
|
5
|
+
cancelFrame(id: number): void;
|
|
6
|
+
setTimeout(callback: () => void, delayMs: number): number;
|
|
7
|
+
clearTimeout(id: number): void;
|
|
8
|
+
}
|
|
9
|
+
declare const browserGameClock: GameClock;
|
|
10
|
+
|
|
11
|
+
export { type GameClock as G, type GameFrameCallback as a, browserGameClock as b };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type GameFrameCallback = (timestamp: number) => void;
|
|
2
|
+
interface GameClock {
|
|
3
|
+
now(): number;
|
|
4
|
+
requestFrame(callback: GameFrameCallback): number;
|
|
5
|
+
cancelFrame(id: number): void;
|
|
6
|
+
setTimeout(callback: () => void, delayMs: number): number;
|
|
7
|
+
clearTimeout(id: number): void;
|
|
8
|
+
}
|
|
9
|
+
declare const browserGameClock: GameClock;
|
|
10
|
+
|
|
11
|
+
export { type GameClock as G, type GameFrameCallback as a, browserGameClock as b };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as Phaser from 'phaser';
|
|
2
2
|
|
|
3
|
+
type EngineWarningMatcher = string | RegExp;
|
|
4
|
+
|
|
3
5
|
interface HeadlessGameHost<TScene extends Phaser.Scene> {
|
|
4
6
|
/** 当前隔离的 Phaser 游戏实例。 */
|
|
5
7
|
game: Phaser.Game;
|
|
@@ -158,6 +160,10 @@ type HeadlessGameOptions = Omit<Phaser.Types.Core.GameConfig, "type" | "scene" |
|
|
|
158
160
|
bootTimeoutMs?: number;
|
|
159
161
|
/** 初始 Scene 启动前必须注册的其他 Scene 类型。 */
|
|
160
162
|
additionalScenes?: Phaser.Types.Scenes.SceneType[];
|
|
163
|
+
/** 显式允许的 Phaser 引擎 warning;其余引擎 warning 会使测试失败。 */
|
|
164
|
+
ignoreEngineWarnings?: readonly EngineWarningMatcher[];
|
|
165
|
+
/** 明确允许 Loader 暂存文件但尚未 start 的 Scene key。 */
|
|
166
|
+
allowIdleLoaderQueues?: readonly string[];
|
|
161
167
|
};
|
|
162
168
|
/** 创建隔离的 Phaser 游戏,并在入口 Scene 完成 create 后返回。 */
|
|
163
169
|
declare function createHeadlessGame<TScene extends Phaser.Scene>(scene: TScene, options?: HeadlessGameOptions): Promise<HeadlessGameHost<TScene>>;
|
|
@@ -205,4 +211,4 @@ interface GameplayContractMetadata {
|
|
|
205
211
|
verified?: boolean;
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
export { type
|
|
214
|
+
export { type EngineWarningMatcher as E, type GameplayAuditOptions as G, type HeadlessGameHost as H, type GameplayContractMetadata as a, type HeadlessGameplayEvidenceRequirements as b, type GameplayAuditContract as c, type HeadlessCanvasBounds as d, type HeadlessGameOptions as e, type HeadlessGameplayEvidence as f, type HeadlessInputDriver as g, type HeadlessInputPoint as h, type HeadlessKeyboardEventOptions as i, type HeadlessKeyboardInput as j, type HeadlessMouseInput as k, type HeadlessTouchInput as l, createHeadlessGame as m };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as Phaser from 'phaser';
|
|
2
2
|
|
|
3
|
+
type EngineWarningMatcher = string | RegExp;
|
|
4
|
+
|
|
3
5
|
interface HeadlessGameHost<TScene extends Phaser.Scene> {
|
|
4
6
|
/** 当前隔离的 Phaser 游戏实例。 */
|
|
5
7
|
game: Phaser.Game;
|
|
@@ -158,6 +160,10 @@ type HeadlessGameOptions = Omit<Phaser.Types.Core.GameConfig, "type" | "scene" |
|
|
|
158
160
|
bootTimeoutMs?: number;
|
|
159
161
|
/** 初始 Scene 启动前必须注册的其他 Scene 类型。 */
|
|
160
162
|
additionalScenes?: Phaser.Types.Scenes.SceneType[];
|
|
163
|
+
/** 显式允许的 Phaser 引擎 warning;其余引擎 warning 会使测试失败。 */
|
|
164
|
+
ignoreEngineWarnings?: readonly EngineWarningMatcher[];
|
|
165
|
+
/** 明确允许 Loader 暂存文件但尚未 start 的 Scene key。 */
|
|
166
|
+
allowIdleLoaderQueues?: readonly string[];
|
|
161
167
|
};
|
|
162
168
|
/** 创建隔离的 Phaser 游戏,并在入口 Scene 完成 create 后返回。 */
|
|
163
169
|
declare function createHeadlessGame<TScene extends Phaser.Scene>(scene: TScene, options?: HeadlessGameOptions): Promise<HeadlessGameHost<TScene>>;
|
|
@@ -205,4 +211,4 @@ interface GameplayContractMetadata {
|
|
|
205
211
|
verified?: boolean;
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
export { type
|
|
214
|
+
export { type EngineWarningMatcher as E, type GameplayAuditOptions as G, type HeadlessGameHost as H, type GameplayContractMetadata as a, type HeadlessGameplayEvidenceRequirements as b, type GameplayAuditContract as c, type HeadlessCanvasBounds as d, type HeadlessGameOptions as e, type HeadlessGameplayEvidence as f, type HeadlessInputDriver as g, type HeadlessInputPoint as h, type HeadlessKeyboardEventOptions as i, type HeadlessKeyboardInput as j, type HeadlessMouseInput as k, type HeadlessTouchInput as l, createHeadlessGame as m };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
1
|
+
import { a as GameplayContractMetadata, H as HeadlessGameHost, b as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-DmbBA0M_.mjs';
|
|
2
|
+
export { E as EngineWarningMatcher, c as GameplayAuditContract, G as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-DmbBA0M_.mjs';
|
|
3
3
|
import * as Phaser from 'phaser';
|
|
4
4
|
import { TestContext, TestOptions } from 'vitest';
|
|
5
5
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
1
|
+
import { a as GameplayContractMetadata, H as HeadlessGameHost, b as HeadlessGameplayEvidenceRequirements } from './gameplay-audit-DmbBA0M_.js';
|
|
2
|
+
export { E as EngineWarningMatcher, c as GameplayAuditContract, G as GameplayAuditOptions, d as HeadlessCanvasBounds, e as HeadlessGameOptions, f as HeadlessGameplayEvidence, g as HeadlessInputDriver, h as HeadlessInputPoint, i as HeadlessKeyboardEventOptions, j as HeadlessKeyboardInput, k as HeadlessMouseInput, l as HeadlessTouchInput, m as createHeadlessGame } from './gameplay-audit-DmbBA0M_.js';
|
|
3
3
|
import * as Phaser from 'phaser';
|
|
4
4
|
import { TestContext, TestOptions } from 'vitest';
|
|
5
5
|
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,75 @@ module.exports = __toCommonJS(src_exports);
|
|
|
46
46
|
// src/phaser-headless-host.ts
|
|
47
47
|
var Phaser3 = __toESM(require("phaser"));
|
|
48
48
|
|
|
49
|
+
// src/phaser-engine-warnings.ts
|
|
50
|
+
var originalWarn;
|
|
51
|
+
var originalError;
|
|
52
|
+
var listener;
|
|
53
|
+
function formatWarningArgument(value) {
|
|
54
|
+
if (typeof value === "string") return value;
|
|
55
|
+
if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
56
|
+
return String(value);
|
|
57
|
+
}
|
|
58
|
+
if (value instanceof Error) return value.message;
|
|
59
|
+
const constructorName = typeof value === "object" ? value.constructor?.name : void 0;
|
|
60
|
+
return constructorName ? `[${constructorName}]` : String(value);
|
|
61
|
+
}
|
|
62
|
+
function comesFromPhaser(stack) {
|
|
63
|
+
return /(?:^|[/\\])phaser(?:[/\\]|\.(?:js|mjs|cjs)(?::\d+)?)/m.test(stack);
|
|
64
|
+
}
|
|
65
|
+
function captureEngineDiagnostic(level, original, args) {
|
|
66
|
+
const stack = new Error(`Phaser engine ${level}`).stack ?? "";
|
|
67
|
+
if (!comesFromPhaser(stack)) {
|
|
68
|
+
original(...args);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
listener?.({
|
|
72
|
+
level,
|
|
73
|
+
message: args.map(formatWarningArgument).join(" "),
|
|
74
|
+
stack
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function installWarningMultiplexer(currentListener) {
|
|
78
|
+
if (listener) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"Only one Phaser HEADLESS host may be active in the same Vitest worker. Destroy the current host before creating another one."
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
listener = currentListener;
|
|
84
|
+
const passthroughWarn = console.warn;
|
|
85
|
+
const passthroughError = console.error;
|
|
86
|
+
originalWarn = passthroughWarn;
|
|
87
|
+
originalError = passthroughError;
|
|
88
|
+
console.warn = (...args) => {
|
|
89
|
+
captureEngineDiagnostic("warn", passthroughWarn, args);
|
|
90
|
+
};
|
|
91
|
+
console.error = (...args) => {
|
|
92
|
+
captureEngineDiagnostic("error", passthroughError, args);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function uninstallWarningMultiplexer() {
|
|
96
|
+
if (!listener) return;
|
|
97
|
+
if (originalWarn) console.warn = originalWarn;
|
|
98
|
+
if (originalError) console.error = originalError;
|
|
99
|
+
listener = void 0;
|
|
100
|
+
originalWarn = void 0;
|
|
101
|
+
originalError = void 0;
|
|
102
|
+
}
|
|
103
|
+
function observeEngineDiagnostics(currentListener) {
|
|
104
|
+
installWarningMultiplexer(currentListener);
|
|
105
|
+
let observing = true;
|
|
106
|
+
return () => {
|
|
107
|
+
if (!observing) return;
|
|
108
|
+
observing = false;
|
|
109
|
+
uninstallWarningMultiplexer();
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function matchesEngineWarning(message, matcher) {
|
|
113
|
+
if (typeof matcher === "string") return message.includes(matcher);
|
|
114
|
+
matcher.lastIndex = 0;
|
|
115
|
+
return matcher.test(message);
|
|
116
|
+
}
|
|
117
|
+
|
|
49
118
|
// src/phaser-text-assertions.ts
|
|
50
119
|
var Phaser = __toESM(require("phaser"));
|
|
51
120
|
function describeText(label) {
|
|
@@ -55,12 +124,15 @@ function describeText(label) {
|
|
|
55
124
|
}
|
|
56
125
|
function isEffectivelyRenderable(label) {
|
|
57
126
|
let current = label;
|
|
127
|
+
const visited = /* @__PURE__ */ new Set();
|
|
58
128
|
while (current) {
|
|
129
|
+
if (visited.has(current)) return false;
|
|
130
|
+
visited.add(current);
|
|
59
131
|
const state = current;
|
|
60
132
|
if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
|
|
61
133
|
return false;
|
|
62
134
|
}
|
|
63
|
-
current = state.parentContainer ?? null;
|
|
135
|
+
current = state.parentContainer ?? (state.displayList instanceof Phaser.GameObjects.Layer ? state.displayList : null);
|
|
64
136
|
}
|
|
65
137
|
return true;
|
|
66
138
|
}
|
|
@@ -236,12 +308,15 @@ function describeObject(object) {
|
|
|
236
308
|
}
|
|
237
309
|
function isEffectivelyVisible(object) {
|
|
238
310
|
let current = object;
|
|
311
|
+
const visited = /* @__PURE__ */ new Set();
|
|
239
312
|
while (current) {
|
|
313
|
+
if (visited.has(current)) return false;
|
|
314
|
+
visited.add(current);
|
|
240
315
|
const state = current;
|
|
241
316
|
if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
|
|
242
317
|
return false;
|
|
243
318
|
}
|
|
244
|
-
current = state.parentContainer ?? null;
|
|
319
|
+
current = state.parentContainer ?? (state.displayList instanceof Phaser2.GameObjects.Layer ? state.displayList : null);
|
|
245
320
|
}
|
|
246
321
|
return true;
|
|
247
322
|
}
|
|
@@ -282,6 +357,12 @@ function collectGameObjectHealthProblems(scene) {
|
|
|
282
357
|
if (!checksObjectState(object)) continue;
|
|
283
358
|
const subject = describeObject(object);
|
|
284
359
|
const target = object;
|
|
360
|
+
const texture = target.texture;
|
|
361
|
+
if (isEffectivelyVisible(object) && texture && typeof texture === "object" && Reflect.get(texture, "key") === "__MISSING") {
|
|
362
|
+
problems.push(
|
|
363
|
+
`${subject} uses Phaser's __MISSING texture; verify the loaded texture key and asset path`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
285
366
|
if (typeof target.setPosition === "function") {
|
|
286
367
|
for (const property of transformProperties) {
|
|
287
368
|
requireFiniteProperty(problems, subject, object, property);
|
|
@@ -419,7 +500,13 @@ function assertSceneInteractiveHealth(scene) {
|
|
|
419
500
|
|
|
420
501
|
// src/phaser-headless-host.ts
|
|
421
502
|
async function createHeadlessGame(scene, options = {}) {
|
|
422
|
-
const {
|
|
503
|
+
const {
|
|
504
|
+
bootTimeoutMs = 2e3,
|
|
505
|
+
additionalScenes = [],
|
|
506
|
+
ignoreEngineWarnings = [],
|
|
507
|
+
allowIdleLoaderQueues = [],
|
|
508
|
+
...config
|
|
509
|
+
} = options;
|
|
423
510
|
let game;
|
|
424
511
|
let settled = false;
|
|
425
512
|
let runtimeError;
|
|
@@ -428,6 +515,16 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
428
515
|
};
|
|
429
516
|
let removeRuntimeListeners = () => {
|
|
430
517
|
};
|
|
518
|
+
const engineDiagnostics = [];
|
|
519
|
+
const loaderFailures = [];
|
|
520
|
+
const stopObservingEngineDiagnostics = observeEngineDiagnostics((diagnostic) => {
|
|
521
|
+
if (diagnostic.level === "warn" && ignoreEngineWarnings.some(
|
|
522
|
+
(matcher) => matchesEngineWarning(diagnostic.message, matcher)
|
|
523
|
+
)) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
engineDiagnostics.push(diagnostic);
|
|
527
|
+
});
|
|
431
528
|
const transitions = [];
|
|
432
529
|
const registeredScenes = [];
|
|
433
530
|
const visitedScenes = [];
|
|
@@ -513,6 +610,25 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
513
610
|
const guardScene = (currentScene) => {
|
|
514
611
|
if (guardedScenes.has(currentScene)) return;
|
|
515
612
|
guardedScenes.add(currentScene);
|
|
613
|
+
const onFileLoadError = (file) => {
|
|
614
|
+
const source = file.src || file.url || "unknown source";
|
|
615
|
+
loaderFailures.push({
|
|
616
|
+
scene: currentScene.sys.settings.key,
|
|
617
|
+
type: String(file.type),
|
|
618
|
+
key: String(file.key),
|
|
619
|
+
source: String(source)
|
|
620
|
+
});
|
|
621
|
+
};
|
|
622
|
+
currentScene.load.on(
|
|
623
|
+
Phaser3.Loader.Events.FILE_LOAD_ERROR,
|
|
624
|
+
onFileLoadError
|
|
625
|
+
);
|
|
626
|
+
restoreGuards.push(() => {
|
|
627
|
+
currentScene.load.off(
|
|
628
|
+
Phaser3.Loader.Events.FILE_LOAD_ERROR,
|
|
629
|
+
onFileLoadError
|
|
630
|
+
);
|
|
631
|
+
});
|
|
516
632
|
const scenePlugin = currentScene.scene;
|
|
517
633
|
for (const method of [
|
|
518
634
|
"start",
|
|
@@ -525,7 +641,12 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
525
641
|
if (typeof original !== "function") continue;
|
|
526
642
|
scenePlugin[method] = (key, ...args) => {
|
|
527
643
|
const target = key === void 0 ? currentScene : typeof key === "string" ? currentScene.scene.get(key) : key;
|
|
528
|
-
if (target)
|
|
644
|
+
if (!target) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
`[SCENE_NOT_REGISTERED] Scene ${JSON.stringify(currentScene.sys.settings.key)} called ${method}(${JSON.stringify(key)}), but the target Scene is not registered. Add it to the production Scene registry and pass it through additionalScenes in focused HEADLESS tests.`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
guardScene(target);
|
|
529
650
|
const from = currentScene.sys.settings.key;
|
|
530
651
|
const to = key === void 0 ? from : typeof key === "string" ? key : key.sys.settings.key;
|
|
531
652
|
if (from && to) transitions.push({ from, to, method });
|
|
@@ -653,6 +774,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
653
774
|
game.headlessStep(game.loop.lastTime, 0);
|
|
654
775
|
}
|
|
655
776
|
restoreHostGuards();
|
|
777
|
+
stopObservingEngineDiagnostics();
|
|
656
778
|
throw error;
|
|
657
779
|
}
|
|
658
780
|
const readyGame = game;
|
|
@@ -660,11 +782,39 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
660
782
|
if (hasRuntimeError) {
|
|
661
783
|
throw runtimeError instanceof Error ? runtimeError : new Error(`Unhandled Phaser runtime error: ${String(runtimeError)}`);
|
|
662
784
|
}
|
|
785
|
+
if (loaderFailures.length > 0) {
|
|
786
|
+
const imageTypes = /* @__PURE__ */ new Set(["image", "normalMap", "spritesheet"]);
|
|
787
|
+
const hasHeadlessImageFailure = loaderFailures.some(
|
|
788
|
+
(failure) => imageTypes.has(failure.type)
|
|
789
|
+
);
|
|
790
|
+
throw new Error(
|
|
791
|
+
[
|
|
792
|
+
"Phaser loader failed to load one or more files:",
|
|
793
|
+
...loaderFailures.map(
|
|
794
|
+
(failure) => `- ${failure.scene}: failed to load ${failure.type} ${JSON.stringify(failure.key)} from ${JSON.stringify(failure.source)}`
|
|
795
|
+
),
|
|
796
|
+
hasHeadlessImageFailure ? "HEADLESS image loading supports data:image/ URLs only; use a data URL or a deliberate loader stub in tests, and verify production asset paths in a browser." : "Verify the resource URL and data, or install a deliberate loader stub for network resources in HEADLESS tests."
|
|
797
|
+
].join("\n")
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
if (engineDiagnostics.length > 0) {
|
|
801
|
+
throw new Error(
|
|
802
|
+
[
|
|
803
|
+
"Phaser emitted an engine diagnostic:",
|
|
804
|
+
...engineDiagnostics.flatMap((diagnostic) => [
|
|
805
|
+
`- ${diagnostic.level}: ${diagnostic.message}`,
|
|
806
|
+
...diagnostic.stack.split("\n").slice(2, 8).map((frame) => ` ${frame.trim()}`)
|
|
807
|
+
]),
|
|
808
|
+
"Fix the invalid Phaser operation. Only known warnings (not errors) may be allowed with ignoreEngineWarnings."
|
|
809
|
+
].join("\n")
|
|
810
|
+
);
|
|
811
|
+
}
|
|
663
812
|
};
|
|
664
813
|
const canvas = readyGame.canvas;
|
|
665
814
|
if (!canvas) {
|
|
666
815
|
removeRuntimeListeners();
|
|
667
816
|
restoreHostGuards();
|
|
817
|
+
stopObservingEngineDiagnostics();
|
|
668
818
|
readyGame.destroy(true);
|
|
669
819
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
670
820
|
throw new Error("Phaser HEADLESS did not create an input canvas");
|
|
@@ -673,6 +823,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
673
823
|
if (!inputWindow) {
|
|
674
824
|
removeRuntimeListeners();
|
|
675
825
|
restoreHostGuards();
|
|
826
|
+
stopObservingEngineDiagnostics();
|
|
676
827
|
readyGame.destroy(true);
|
|
677
828
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
678
829
|
throw new Error(
|
|
@@ -728,6 +879,14 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
728
879
|
if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
|
|
729
880
|
continue;
|
|
730
881
|
appendUnique(visitedScenes, currentScene.sys.settings.key);
|
|
882
|
+
if (!allowIdleLoaderQueues.includes(currentScene.sys.settings.key) && !currentScene.load.isLoading() && currentScene.load.list.size > 0) {
|
|
883
|
+
const queuedFiles = [...currentScene.load.list].map(
|
|
884
|
+
(file) => `${file.type}:${String(file.key)}`
|
|
885
|
+
);
|
|
886
|
+
throw new Error(
|
|
887
|
+
`Phaser loader queue is not running in scene ${JSON.stringify(currentScene.sys.settings.key)}: ${queuedFiles.length} file(s) are queued (${queuedFiles.join(", ")}). Files added outside preload() require this.load.start().`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
731
890
|
assertSceneTextHealth(currentScene);
|
|
732
891
|
assertSceneInteractiveHealth(currentScene);
|
|
733
892
|
assertSceneRuntimeHealth(currentScene);
|
|
@@ -953,6 +1112,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
953
1112
|
} catch (error) {
|
|
954
1113
|
removeRuntimeListeners();
|
|
955
1114
|
restoreHostGuards();
|
|
1115
|
+
stopObservingEngineDiagnostics();
|
|
956
1116
|
readyGame.destroy(true);
|
|
957
1117
|
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
958
1118
|
throw error;
|
|
@@ -1084,6 +1244,7 @@ async function createHeadlessGame(scene, options = {}) {
|
|
|
1084
1244
|
} finally {
|
|
1085
1245
|
removeRuntimeListeners();
|
|
1086
1246
|
restoreHostGuards();
|
|
1247
|
+
stopObservingEngineDiagnostics();
|
|
1087
1248
|
}
|
|
1088
1249
|
}
|
|
1089
1250
|
};
|