miaoda-game-devkit 0.2.9 → 0.2.10
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 +21 -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-BwAobjIX.d.mts} +1 -1
- package/dist/{gameplay-audit-CCJo4Qhk.d.ts → gameplay-audit-BwAobjIX.d.ts} +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/lint/vitest-config.contract.mjs +213 -8
- 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 +1 -1
- package/dist/vitest-config.d.ts +1 -1
- package/dist/vitest-config.js +46 -2
- package/dist/vitest-config.mjs +46 -2
- 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 才绑定
|
|
@@ -54,6 +60,20 @@ monorepo 中的模板可以放置只包含 `extends: ["miaoda-game-devkit/biome"
|
|
|
54
60
|
别名等项目差异。TypeScript 严格模式、bundler 模块解析和无输出检查等平台基线由开发
|
|
55
61
|
工具包集中维护,`miaoda-game-lint` 会直接检查模板的这个配置。
|
|
56
62
|
|
|
63
|
+
Phaser4 模板的 `vite.config.ts` 应从 `miaoda-game-devkit/vite` 导入 `defineGameViteConfig`,调用方式
|
|
64
|
+
与 Vite 的 `defineConfig` 相同,支持配置对象、Promise、同步配置函数和异步配置函数:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
export default defineGameViteConfig({
|
|
68
|
+
plugins: [miaodaDevPlugin()],
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
该入口只增强 `src/scenes` 中对 Phaser 的导入。游戏 Scene 仍按标准方式继承
|
|
73
|
+
`Phaser.Scene`;devkit 在 Scene 创建前提供 RexUI,并在创建后验证业务确实完成了 RexUI
|
|
74
|
+
根布局。RexUI 的加载、Phaser 4.0 兼容和 Scene Plugin 生命周期属于 devkit 责任,游戏
|
|
75
|
+
配置不重复注册。
|
|
76
|
+
|
|
57
77
|
具体游戏的玩法测试仍由模板使用方放在 `tests/`。运行时测试从本开发工具包
|
|
58
78
|
导入 `createHeadlessGame`;有限目标游戏通过合法 Telemetry controls 走到正常成功终局,
|
|
59
79
|
无尽或沙盒游戏证明有意义的持续进展。短输入测试从入口操作走到一个主要操作,用真实 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 };
|
|
@@ -205,4 +205,4 @@ interface GameplayContractMetadata {
|
|
|
205
205
|
verified?: boolean;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
export { type
|
|
208
|
+
export { 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 };
|
|
@@ -205,4 +205,4 @@ interface GameplayContractMetadata {
|
|
|
205
205
|
verified?: boolean;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
export { type
|
|
208
|
+
export { 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-BwAobjIX.mjs';
|
|
2
|
+
export { 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-BwAobjIX.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-BwAobjIX.js';
|
|
2
|
+
export { 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-BwAobjIX.js';
|
|
3
3
|
import * as Phaser from 'phaser';
|
|
4
4
|
import { TestContext, TestOptions } from 'vitest';
|
|
5
5
|
|
|
@@ -1,14 +1,78 @@
|
|
|
1
1
|
// src/lint/vitest-config.test.ts
|
|
2
|
-
import { resolve as
|
|
2
|
+
import { resolve as resolve4 } from "path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
+
// src/vite.ts
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { createRequire } from "module";
|
|
8
|
+
import { dirname, join, normalize, resolve, sep } from "path";
|
|
9
|
+
var PHASER_MODULE_ID = "phaser";
|
|
10
|
+
function isPromiseLike(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
12
|
+
}
|
|
13
|
+
function resolvePhaserFacade(projectRoot2) {
|
|
14
|
+
const requireFromProject = createRequire(join(projectRoot2, "package.json"));
|
|
15
|
+
const viteEntry = requireFromProject.resolve("miaoda-game-devkit/vite");
|
|
16
|
+
const facade = join(dirname(viteEntry), "phaser-facade.mjs");
|
|
17
|
+
if (!existsSync(facade)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`miaoda-game-devkit \u7F3A\u5C11 Phaser facade\uFF1A${facade}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return facade;
|
|
23
|
+
}
|
|
24
|
+
function isInsideDirectory(filePath, directory) {
|
|
25
|
+
const normalizedFile = normalize(filePath);
|
|
26
|
+
const normalizedDirectory = `${normalize(directory)}${sep}`;
|
|
27
|
+
return normalizedFile.startsWith(normalizedDirectory);
|
|
28
|
+
}
|
|
29
|
+
function phaserRexUIScenePlugin(projectRoot2) {
|
|
30
|
+
let scenesRoot = projectRoot2 ? resolve(projectRoot2, "src/scenes") : void 0;
|
|
31
|
+
let phaserFacade = projectRoot2 ? resolvePhaserFacade(projectRoot2) : void 0;
|
|
32
|
+
return {
|
|
33
|
+
name: "miaoda-phaser-rexui-scene",
|
|
34
|
+
enforce: "pre",
|
|
35
|
+
configResolved(config) {
|
|
36
|
+
const resolvedProjectRoot = projectRoot2 ?? config.root;
|
|
37
|
+
scenesRoot ??= resolve(resolvedProjectRoot, "src/scenes");
|
|
38
|
+
phaserFacade ??= resolvePhaserFacade(resolvedProjectRoot);
|
|
39
|
+
},
|
|
40
|
+
/** 查询参数属于构建工具元数据,去除后才能稳定判断真实源码位置。 */
|
|
41
|
+
resolveId(source, importer) {
|
|
42
|
+
if (source !== PHASER_MODULE_ID || !importer || !scenesRoot) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const cleanImporter = importer.split("?", 1)[0] ?? importer;
|
|
46
|
+
return isInsideDirectory(cleanImporter, scenesRoot) ? phaserFacade ?? null : null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function withGameDefaults(config) {
|
|
51
|
+
return {
|
|
52
|
+
...config,
|
|
53
|
+
plugins: [
|
|
54
|
+
phaserRexUIScenePlugin(),
|
|
55
|
+
...config.plugins ?? []
|
|
56
|
+
]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function defineGameViteConfig(config) {
|
|
60
|
+
if (typeof config === "function") {
|
|
61
|
+
return (env) => {
|
|
62
|
+
const resolved = config(env);
|
|
63
|
+
return isPromiseLike(resolved) ? resolved.then(withGameDefaults) : withGameDefaults(resolved);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return isPromiseLike(config) ? config.then(withGameDefaults) : withGameDefaults(config);
|
|
67
|
+
}
|
|
68
|
+
|
|
5
69
|
// src/vitest-config.ts
|
|
6
|
-
import { resolve } from "path";
|
|
70
|
+
import { resolve as resolve2 } from "path";
|
|
7
71
|
import { defineConfig } from "vitest/config";
|
|
8
72
|
|
|
9
73
|
// src/gameplay-audit.ts
|
|
10
74
|
import { readdirSync, readFileSync, statSync } from "fs";
|
|
11
|
-
import { join } from "path";
|
|
75
|
+
import { join as join2 } from "path";
|
|
12
76
|
function normalizeList(value) {
|
|
13
77
|
if (value === void 0) return [];
|
|
14
78
|
const values = typeof value === "string" ? [value] : value;
|
|
@@ -157,7 +221,7 @@ function validateGameplayAuditOptions(options, projectRoot2) {
|
|
|
157
221
|
return issues;
|
|
158
222
|
}
|
|
159
223
|
function containsProductionRestart(projectRoot2) {
|
|
160
|
-
const sceneRoot =
|
|
224
|
+
const sceneRoot = join2(projectRoot2, "src", "scenes");
|
|
161
225
|
const visit = (directory) => {
|
|
162
226
|
let entries;
|
|
163
227
|
try {
|
|
@@ -166,7 +230,7 @@ function containsProductionRestart(projectRoot2) {
|
|
|
166
230
|
return false;
|
|
167
231
|
}
|
|
168
232
|
for (const entry of entries) {
|
|
169
|
-
const path =
|
|
233
|
+
const path = join2(directory, entry);
|
|
170
234
|
let stats;
|
|
171
235
|
try {
|
|
172
236
|
stats = statSync(path);
|
|
@@ -617,10 +681,11 @@ function defineGameVitestConfig(options) {
|
|
|
617
681
|
);
|
|
618
682
|
}
|
|
619
683
|
return defineConfig({
|
|
684
|
+
plugins: [phaserRexUIScenePlugin(options.projectRoot)],
|
|
620
685
|
resolve: {
|
|
621
686
|
alias: {
|
|
622
687
|
...options.aliases,
|
|
623
|
-
"@":
|
|
688
|
+
"@": resolve2(options.projectRoot, "src")
|
|
624
689
|
}
|
|
625
690
|
},
|
|
626
691
|
// devkit 作为 external ESM 加载时,应用测试也必须 externalize Phaser,
|
|
@@ -680,8 +745,93 @@ function defineGameVitestConfig(options) {
|
|
|
680
745
|
});
|
|
681
746
|
}
|
|
682
747
|
|
|
748
|
+
// src/react-vitest-config.ts
|
|
749
|
+
import { resolve as resolve3 } from "path";
|
|
750
|
+
import { defineConfig as defineConfig2 } from "vitest/config";
|
|
751
|
+
function defineReactGameVitestConfig(options) {
|
|
752
|
+
return defineConfig2({
|
|
753
|
+
resolve: {
|
|
754
|
+
alias: { ...options.aliases, "@": resolve3(options.projectRoot, "src") }
|
|
755
|
+
},
|
|
756
|
+
test: {
|
|
757
|
+
include: [
|
|
758
|
+
"src/**/*.{test,spec}.{ts,tsx}",
|
|
759
|
+
"tests/**/*.{test,spec}.{ts,tsx}"
|
|
760
|
+
],
|
|
761
|
+
environment: "jsdom",
|
|
762
|
+
environmentOptions: {
|
|
763
|
+
jsdom: { url: "http://localhost/", pretendToBeVisual: true }
|
|
764
|
+
},
|
|
765
|
+
setupFiles: [
|
|
766
|
+
"miaoda-game-devkit/react/vitest-setup",
|
|
767
|
+
...options.additionalSetupFiles ?? []
|
|
768
|
+
],
|
|
769
|
+
reporters: ["minimal"],
|
|
770
|
+
restoreMocks: true,
|
|
771
|
+
clearMocks: true,
|
|
772
|
+
testTimeout: options.testTimeout,
|
|
773
|
+
hookTimeout: options.hookTimeout
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// src/react/manual-game-clock.ts
|
|
779
|
+
var ManualGameClock = class {
|
|
780
|
+
time = 0;
|
|
781
|
+
nextId = 1;
|
|
782
|
+
frames = /* @__PURE__ */ new Map();
|
|
783
|
+
timers = /* @__PURE__ */ new Map();
|
|
784
|
+
now() {
|
|
785
|
+
return this.time;
|
|
786
|
+
}
|
|
787
|
+
requestFrame(callback) {
|
|
788
|
+
const id = this.nextId++;
|
|
789
|
+
this.frames.set(id, callback);
|
|
790
|
+
return id;
|
|
791
|
+
}
|
|
792
|
+
cancelFrame(id) {
|
|
793
|
+
this.frames.delete(id);
|
|
794
|
+
}
|
|
795
|
+
setTimeout(callback, delayMs) {
|
|
796
|
+
const id = this.nextId++;
|
|
797
|
+
this.timers.set(id, { callback, dueAt: this.time + delayMs });
|
|
798
|
+
return id;
|
|
799
|
+
}
|
|
800
|
+
clearTimeout(id) {
|
|
801
|
+
this.timers.delete(id);
|
|
802
|
+
}
|
|
803
|
+
advanceBy(deltaMs) {
|
|
804
|
+
if (!Number.isFinite(deltaMs) || deltaMs < 0) {
|
|
805
|
+
throw new RangeError("deltaMs must be a non-negative finite number");
|
|
806
|
+
}
|
|
807
|
+
this.time += deltaMs;
|
|
808
|
+
const dueTimers = [...this.timers.entries()].filter(([, timer]) => timer.dueAt <= this.time).sort((left, right) => left[1].dueAt - right[1].dueAt);
|
|
809
|
+
for (const [id, timer] of dueTimers) {
|
|
810
|
+
if (timer.dueAt <= this.time && this.timers.delete(id)) timer.callback();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
stepFrame(deltaMs = 16) {
|
|
814
|
+
this.advanceBy(deltaMs);
|
|
815
|
+
const callbacks = [...this.frames.values()];
|
|
816
|
+
this.frames.clear();
|
|
817
|
+
for (const callback of callbacks) callback(this.time);
|
|
818
|
+
}
|
|
819
|
+
stepFrames(count, deltaMs = 16) {
|
|
820
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
821
|
+
throw new RangeError("count must be a non-negative integer");
|
|
822
|
+
}
|
|
823
|
+
for (let index = 0; index < count; index += 1) this.stepFrame(deltaMs);
|
|
824
|
+
}
|
|
825
|
+
pendingFrameCount() {
|
|
826
|
+
return this.frames.size;
|
|
827
|
+
}
|
|
828
|
+
pendingTimerCount() {
|
|
829
|
+
return this.timers.size;
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
|
|
683
833
|
// src/lint/vitest-config.test.ts
|
|
684
|
-
var projectRoot =
|
|
834
|
+
var projectRoot = resolve4(import.meta.dirname, "../..");
|
|
685
835
|
var gameplayAudit = {
|
|
686
836
|
mode: "non-interactive",
|
|
687
837
|
nonInteractiveJustification: "\u56FA\u5B9A\u5939\u5177\u53EA\u9A8C\u8BC1\u5171\u4EAB host \u57FA\u7EBF\uFF0C\u4E0D\u5305\u542B\u73A9\u6CD5\u8F93\u5165\u3002",
|
|
@@ -697,11 +847,29 @@ var gameplayAudit = {
|
|
|
697
847
|
]
|
|
698
848
|
};
|
|
699
849
|
describe("defineGameVitestConfig", () => {
|
|
850
|
+
it("defineGameViteConfig \u4FDD\u6301 Vite defineConfig \u7684\u5E38\u7528\u8F93\u5165\u5F62\u5F0F", async () => {
|
|
851
|
+
const objectConfig = defineGameViteConfig({ plugins: [] });
|
|
852
|
+
expect(objectConfig.plugins?.[0]).toMatchObject({
|
|
853
|
+
name: "miaoda-phaser-rexui-scene"
|
|
854
|
+
});
|
|
855
|
+
const promiseConfig = await defineGameViteConfig(Promise.resolve({}));
|
|
856
|
+
expect(promiseConfig.plugins?.[0]).toMatchObject({
|
|
857
|
+
name: "miaoda-phaser-rexui-scene"
|
|
858
|
+
});
|
|
859
|
+
const functionConfig = defineGameViteConfig(() => ({ resolve: {} }));
|
|
860
|
+
const resolvedFunctionConfig = functionConfig({ command: "serve", mode: "test" });
|
|
861
|
+
expect(resolvedFunctionConfig.plugins?.[0]).toMatchObject({
|
|
862
|
+
name: "miaoda-phaser-rexui-scene"
|
|
863
|
+
});
|
|
864
|
+
});
|
|
700
865
|
it("\u63D0\u4F9B Phaser \u8FD0\u884C\u65F6\u6D4B\u8BD5\u9700\u8981\u7684\u56FA\u5B9A\u57FA\u7EBF", () => {
|
|
701
866
|
const config = defineGameVitestConfig({ projectRoot, gameplayAudit });
|
|
702
867
|
expect(config.resolve?.alias).toMatchObject({
|
|
703
|
-
"@":
|
|
868
|
+
"@": resolve4(projectRoot, "src")
|
|
704
869
|
});
|
|
870
|
+
expect(config.plugins).toEqual([
|
|
871
|
+
expect.objectContaining({ name: "miaoda-phaser-rexui-scene" })
|
|
872
|
+
]);
|
|
705
873
|
expect(config.resolve?.alias).not.toHaveProperty("phaser");
|
|
706
874
|
expect(config.ssr).toMatchObject({ external: ["phaser"] });
|
|
707
875
|
expect(config.test).toMatchObject({
|
|
@@ -775,3 +943,40 @@ describe("defineGameVitestConfig", () => {
|
|
|
775
943
|
).toThrow(/缺少最低玩法基线/);
|
|
776
944
|
});
|
|
777
945
|
});
|
|
946
|
+
describe("React game devkit", () => {
|
|
947
|
+
it("\u63D0\u4F9B\u9694\u79BB\u4E8E Phaser \u5BA1\u8BA1\u7684 JSDOM \u914D\u7F6E", () => {
|
|
948
|
+
const config = defineReactGameVitestConfig({ projectRoot });
|
|
949
|
+
expect(config.resolve?.alias).toMatchObject({
|
|
950
|
+
"@": resolve4(projectRoot, "src")
|
|
951
|
+
});
|
|
952
|
+
expect(config.ssr).toBeUndefined();
|
|
953
|
+
expect(config.test).toMatchObject({
|
|
954
|
+
environment: "jsdom",
|
|
955
|
+
environmentOptions: {
|
|
956
|
+
jsdom: { url: "http://localhost/", pretendToBeVisual: true }
|
|
957
|
+
},
|
|
958
|
+
include: [
|
|
959
|
+
"src/**/*.{test,spec}.{ts,tsx}",
|
|
960
|
+
"tests/**/*.{test,spec}.{ts,tsx}"
|
|
961
|
+
],
|
|
962
|
+
setupFiles: ["miaoda-game-devkit/react/vitest-setup"],
|
|
963
|
+
reporters: ["minimal"],
|
|
964
|
+
restoreMocks: true,
|
|
965
|
+
clearMocks: true
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
it("\u624B\u52A8\u65F6\u949F\u786E\u5B9A\u6027\u63A8\u8FDB\u5E76\u6E05\u7406\u5E27\u548C\u5B9A\u65F6\u5668", () => {
|
|
969
|
+
const clock = new ManualGameClock();
|
|
970
|
+
let frames = 0;
|
|
971
|
+
const timers = [];
|
|
972
|
+
clock.requestFrame(() => frames++);
|
|
973
|
+
clock.setTimeout(() => timers.push("late"), 20);
|
|
974
|
+
clock.setTimeout(() => timers.push("early"), 10);
|
|
975
|
+
clock.stepFrame(10);
|
|
976
|
+
expect({ frames, timers }).toEqual({ frames: 1, timers: ["early"] });
|
|
977
|
+
clock.advanceBy(10);
|
|
978
|
+
expect({ frames, timers }).toEqual({ frames: 1, timers: ["early", "late"] });
|
|
979
|
+
expect(clock.pendingFrameCount()).toBe(0);
|
|
980
|
+
expect(clock.pendingTimerCount()).toBe(0);
|
|
981
|
+
});
|
|
982
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/phaser/phaser-facade.js
|
|
2
|
+
import * as PhaserNamespace from "phaser";
|
|
3
|
+
|
|
4
|
+
// src/phaser/rexui-scene.ts
|
|
5
|
+
import * as Phaser2 from "phaser";
|
|
6
|
+
|
|
7
|
+
// src/phaser/rexui-plugin.ts
|
|
8
|
+
import * as Phaser from "phaser";
|
|
9
|
+
var globalScope = globalThis;
|
|
10
|
+
globalScope.Phaser ??= Phaser;
|
|
11
|
+
var RexClass = (
|
|
12
|
+
// @ts-expect-error RexUI 未发布这个内部兼容模块的类型声明。
|
|
13
|
+
(await import("phaser4-rex-plugins/plugins/utils/object/Class.js")).default
|
|
14
|
+
);
|
|
15
|
+
var phaserNamespace = globalScope.Phaser;
|
|
16
|
+
if (!phaserNamespace.Class) {
|
|
17
|
+
globalScope.Phaser = { ...globalScope.Phaser, Class: RexClass };
|
|
18
|
+
}
|
|
19
|
+
var RexUIPlugin = (await import("phaser4-rex-plugins/templates/ui/ui-plugin.js")).default;
|
|
20
|
+
|
|
21
|
+
// src/phaser/rexui-scene.ts
|
|
22
|
+
var REXUI_LAYOUT_INSTRUMENTED = /* @__PURE__ */ Symbol("miaoda.rexui-layout-instrumented");
|
|
23
|
+
function isPromiseLike(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
25
|
+
}
|
|
26
|
+
var RexUIScene = class extends Phaser2.Scene {
|
|
27
|
+
/** 保存真实布局证据,用于排除“只注册或只创建组件但没有布局”的情况。 */
|
|
28
|
+
rexUILayoutRoots = /* @__PURE__ */ new Set();
|
|
29
|
+
/** 保证 Scene restart 时不会给同一个 RexUI 工厂重复叠加代理。 */
|
|
30
|
+
instrumentedFactories = /* @__PURE__ */ new WeakSet();
|
|
31
|
+
/**
|
|
32
|
+
* 包装业务 Scene 的 create,而不要求业务代码显式调用 super.create()。
|
|
33
|
+
* Phaser 执行 create 时插件已完成注入,因此可在业务 create 前安装监测,
|
|
34
|
+
* 并在业务 create 返回后立即验证最终布局。
|
|
35
|
+
*/
|
|
36
|
+
constructor(config) {
|
|
37
|
+
super(config);
|
|
38
|
+
const scene = this;
|
|
39
|
+
const create = scene.create;
|
|
40
|
+
scene.create = (...args) => {
|
|
41
|
+
this.ensureRexUIPlugin();
|
|
42
|
+
this.instrumentRexUIFactory();
|
|
43
|
+
this.sys.events.once(Phaser2.Scenes.Events.SHUTDOWN, () => {
|
|
44
|
+
this.rexUILayoutRoots.clear();
|
|
45
|
+
});
|
|
46
|
+
const result = create?.apply(this, args);
|
|
47
|
+
if (isPromiseLike(result)) {
|
|
48
|
+
return result.then((value) => {
|
|
49
|
+
this.assertRexUILayout();
|
|
50
|
+
return value;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
this.assertRexUILayout();
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 保证业务 create 开始前已经拥有可用的 `this.rexUI`。
|
|
59
|
+
* 注册由 devkit 统一负责,避免应用配置被修改后出现“类型存在、运行时未注入”。
|
|
60
|
+
*/
|
|
61
|
+
ensureRexUIPlugin() {
|
|
62
|
+
const scene = this;
|
|
63
|
+
if (scene.rexUI?.add) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.plugins.installScenePlugin("rexUI", RexUIPlugin, "rexUI", this, true);
|
|
67
|
+
if (!scene.rexUI?.add) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`[UI_INITIALIZATION_FAILED] ${this.sys.settings.key || this.constructor.name} \u754C\u9762\u521D\u59CB\u5316\u5931\u8D25\u3002`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 记录业务通过 `this.rexUI.add.*` 创建的 Sizer。
|
|
75
|
+
* 这一步用于区分“插件可用”和“业务确实采用 RexUI 组织界面”,同时不改变工厂返回值。
|
|
76
|
+
*/
|
|
77
|
+
instrumentRexUIFactory() {
|
|
78
|
+
const rexUI = this.rexUI;
|
|
79
|
+
if (!rexUI?.add) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const factory = rexUI.add;
|
|
83
|
+
if (this.instrumentedFactories.has(factory)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const wrappedMethods = /* @__PURE__ */ new Map();
|
|
87
|
+
const instrumentedFactory = new Proxy(factory, {
|
|
88
|
+
get: (target, property, receiver) => {
|
|
89
|
+
const value = Reflect.get(target, property, receiver);
|
|
90
|
+
if (typeof value !== "function") {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
let wrapped = wrappedMethods.get(property);
|
|
94
|
+
if (!wrapped) {
|
|
95
|
+
wrapped = (...args) => {
|
|
96
|
+
const result = Reflect.apply(value, target, args);
|
|
97
|
+
this.instrumentSizer(result);
|
|
98
|
+
return result;
|
|
99
|
+
};
|
|
100
|
+
wrappedMethods.set(property, wrapped);
|
|
101
|
+
}
|
|
102
|
+
return wrapped;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
rexUI.add = instrumentedFactory;
|
|
106
|
+
this.instrumentedFactories.add(instrumentedFactory);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 为单个 Sizer 采集可靠的布局完成证据。
|
|
110
|
+
* RexUI 没有公开持久的“layout 已调用”状态,因此必须在原始 layout 成功返回后记录;
|
|
111
|
+
* 该证据能发现组件已经创建但仍因漏调 layout() 而重叠的页面。
|
|
112
|
+
*/
|
|
113
|
+
instrumentSizer(candidate) {
|
|
114
|
+
if (!candidate?.isRexSizer || typeof candidate.layout !== "function" || candidate[REXUI_LAYOUT_INSTRUMENTED]) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const originalLayout = candidate.layout;
|
|
118
|
+
candidate.layout = (...args) => {
|
|
119
|
+
const result = Reflect.apply(originalLayout, candidate, args);
|
|
120
|
+
this.rexUILayoutRoots.add(candidate);
|
|
121
|
+
return result;
|
|
122
|
+
};
|
|
123
|
+
candidate[REXUI_LAYOUT_INSTRUMENTED] = true;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 在业务 create 完成后验证 RexUI 使用情况。
|
|
127
|
+
* 合格布局必须属于当前 Scene、调用过 layout(),并且自身不是另一 Sizer 的子级。
|
|
128
|
+
*/
|
|
129
|
+
assertRexUILayout() {
|
|
130
|
+
const sceneKey = this.sys.settings.key || this.constructor.name;
|
|
131
|
+
const hasLaidOutRoot = [...this.rexUILayoutRoots].some((sizer) => {
|
|
132
|
+
const parent = sizer.getParentSizer?.();
|
|
133
|
+
return !parent && sizer.scene === this;
|
|
134
|
+
});
|
|
135
|
+
if (hasLaidOutRoot) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[REXUI_LAYOUT_REQUIRED] ${sceneKey} \u6CA1\u6709\u5B8C\u6210 RexUI \u6839\u5E03\u5C40\u3002
|
|
140
|
+
\u8BF7\u4F7F\u7528 this.rexUI.add.sizer\u3001gridSizer\u3001scrollablePanel \u6216\u5176\u4ED6\u5408\u9002\u7684 RexUI \u5BB9\u5668\u521B\u5EFA UI \u6839\u5E03\u5C40\uFF0C\u628A HUD\u3001\u83DC\u5355\u3001\u9762\u677F\u548C\u6309\u94AE\u52A0\u5165\u8BE5\u5E03\u5C40\uFF0C\u5E76\u5728\u7EC4\u5408\u6216\u5C3A\u5BF8\u53D8\u5316\u540E\u8C03\u7528 .layout()\u3002
|
|
141
|
+
\u4E0D\u8981\u4F7F\u7528 container + rectangle + text \u6216 this.add.text(...).setInteractive() \u624B\u5DE5\u62FC\u88C5 Canvas UI\u3002`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// src/phaser/phaser-facade.js
|
|
147
|
+
export * from "phaser";
|
|
148
|
+
var PhaserFacade = Object.freeze({
|
|
149
|
+
...PhaserNamespace,
|
|
150
|
+
Scene: RexUIScene
|
|
151
|
+
});
|
|
152
|
+
var phaser_facade_default = PhaserFacade;
|
|
153
|
+
export {
|
|
154
|
+
RexUIScene as Scene,
|
|
155
|
+
phaser_facade_default as default
|
|
156
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { G as GameClock, a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.mjs';
|
|
2
|
+
|
|
3
|
+
interface DisposableGameSession {
|
|
4
|
+
destroy(): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* 创建由当前组件拥有的 Session,并在真实卸载后销毁。
|
|
8
|
+
* 微任务检查避免 React Strict Mode 的 effect 探测误销毁仍在使用的 Session。
|
|
9
|
+
* createSession 必须只创建内存状态;监听器、定时器等外部资源应在订阅或操作时启用。
|
|
10
|
+
*/
|
|
11
|
+
declare function useOwnedGameSession<TSession extends DisposableGameSession>(createSession: () => TSession): TSession;
|
|
12
|
+
|
|
13
|
+
export { type DisposableGameSession, useOwnedGameSession };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { G as GameClock, a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.js';
|
|
2
|
+
|
|
3
|
+
interface DisposableGameSession {
|
|
4
|
+
destroy(): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* 创建由当前组件拥有的 Session,并在真实卸载后销毁。
|
|
8
|
+
* 微任务检查避免 React Strict Mode 的 effect 探测误销毁仍在使用的 Session。
|
|
9
|
+
* createSession 必须只创建内存状态;监听器、定时器等外部资源应在订阅或操作时启用。
|
|
10
|
+
*/
|
|
11
|
+
declare function useOwnedGameSession<TSession extends DisposableGameSession>(createSession: () => TSession): TSession;
|
|
12
|
+
|
|
13
|
+
export { type DisposableGameSession, useOwnedGameSession };
|