miaoda-game-devkit 0.2.8 → 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 +39 -13
- 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-CusQfLYQ.d.mts → gameplay-audit-BwAobjIX.d.mts} +3 -3
- package/dist/{gameplay-audit-CusQfLYQ.d.ts → gameplay-audit-BwAobjIX.d.ts} +3 -3
- package/dist/index.d.mts +26 -3
- package/dist/index.d.ts +26 -3
- package/dist/index.js +52 -0
- package/dist/index.mjs +49 -0
- package/dist/lint/game-telemetry.contract.mjs +1241 -0
- package/dist/lint/gameplay-audit.contract.mjs +111 -46
- package/dist/lint/gameplay-contract.contract.mjs +5 -2
- package/dist/lint/vitest-config.contract.mjs +262 -37
- 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 +95 -31
- package/dist/vitest-config.mjs +95 -31
- package/package.json +72 -2
|
@@ -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;
|
|
@@ -50,6 +114,7 @@ function createGameplayContractMetadata(contract) {
|
|
|
50
114
|
}
|
|
51
115
|
function validateGameplayAuditOptions(options, projectRoot2) {
|
|
52
116
|
const issues = [];
|
|
117
|
+
const contracts = options.contracts;
|
|
53
118
|
const scenes = [...new Set(options.scenes.map((scene) => scene.trim()).filter(Boolean))];
|
|
54
119
|
const mode = options.mode ?? "interactive";
|
|
55
120
|
if (mode === "non-interactive") {
|
|
@@ -71,7 +136,7 @@ function validateGameplayAuditOptions(options, projectRoot2) {
|
|
|
71
136
|
const coveredScenes = /* @__PURE__ */ new Set();
|
|
72
137
|
const kinds = /* @__PURE__ */ new Map();
|
|
73
138
|
const invalidKindContracts = [];
|
|
74
|
-
for (const contract of
|
|
139
|
+
for (const contract of contracts ?? []) {
|
|
75
140
|
const id = contract.id.trim();
|
|
76
141
|
if (!id) {
|
|
77
142
|
issues.push("gameplayAudit.contracts \u4E2D\u5B58\u5728\u7A7A contract id\u3002");
|
|
@@ -97,7 +162,6 @@ function validateGameplayAuditOptions(options, projectRoot2) {
|
|
|
97
162
|
kinds.set(contract.kind, list);
|
|
98
163
|
}
|
|
99
164
|
if (contract.kind === "playthrough") {
|
|
100
|
-
if (!contract.requireInput) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
101
165
|
if (!contract.requireFrameAdvance) issues.push(`playthrough contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
102
166
|
if (normalizeList(contract.requireCheckpoint).length === 0) issues.push(`playthrough contract "${id}" \u5FC5\u987B\u58F0\u660E requireCheckpoint\u3002`);
|
|
103
167
|
}
|
|
@@ -105,7 +169,6 @@ function validateGameplayAuditOptions(options, projectRoot2) {
|
|
|
105
169
|
issues.push(`transition contract "${id}" \u5FC5\u987B\u58F0\u660E requireTransition\u3002`);
|
|
106
170
|
}
|
|
107
171
|
if (contract.kind === "recovery") {
|
|
108
|
-
if (!contract.requireInput) issues.push(`recovery contract "${id}" \u5FC5\u987B requireInput: true\u3002`);
|
|
109
172
|
if (!contract.requireFrameAdvance) issues.push(`recovery contract "${id}" \u5FC5\u987B requireFrameAdvance: true\u3002`);
|
|
110
173
|
if (normalizeList(contract.requireRestart).length === 0) issues.push(`recovery contract "${id}" \u5FC5\u987B\u58F0\u660E requireRestart\u3002`);
|
|
111
174
|
if (normalizeList(contract.requireCheckpoint).length < 2) issues.push(`recovery contract "${id}" \u81F3\u5C11\u9700\u8981\u4E24\u4E2A checkpoint\uFF08\u91CD\u7F6E\u524D\u540E\uFF09\u3002`);
|
|
@@ -127,31 +190,24 @@ function validateGameplayAuditOptions(options, projectRoot2) {
|
|
|
127
190
|
`\u4EE5\u4E0B contract \u7F3A\u5C11\u6709\u6548 kind\uFF1A${invalidKindContracts.map((id) => `"${id}"`).join("\u3001")}\u3002\u8BF7\u4E3A\u6BCF\u4E2A contract \u8BBE\u7F6E kind: "boot"\u3001"playthrough"\u3001"transition" \u6216 "recovery"\u3002`
|
|
128
191
|
);
|
|
129
192
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
193
|
+
if (contracts) {
|
|
194
|
+
for (const scene of scenes) {
|
|
195
|
+
if (!coveredScenes.has(scene)) {
|
|
196
|
+
issues.push(`Scene "${scene}" \u6CA1\u6709\u4EFB\u4F55 gameplay contract \u8D1F\u8D23\u9A8C\u8BC1\u3002`);
|
|
197
|
+
}
|
|
133
198
|
}
|
|
199
|
+
if (contracts.length === 0)
|
|
200
|
+
issues.push("gameplayAudit.contracts \u5B58\u5728\u65F6\u4E0D\u80FD\u4E3A\u7A7A\uFF1B\u4E0D\u4F7F\u7528\u663E\u5F0F\u6E05\u5355\u8BF7\u7701\u7565\u8BE5\u5B57\u6BB5\u3002");
|
|
134
201
|
}
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const hasInput =
|
|
140
|
-
|
|
141
|
-
if (!hasPlaythrough || !hasInput || !hasCheckpoint) {
|
|
202
|
+
if (mode === "interactive" && contracts) {
|
|
203
|
+
const hasPlaythrough = (kinds.get("playthrough") ?? []).some(
|
|
204
|
+
(contract) => Boolean(contract.requireFrameAdvance) && normalizeList(contract.requireCheckpoint).length > 0
|
|
205
|
+
);
|
|
206
|
+
const hasInput = contracts.some((contract) => contract.requireInput);
|
|
207
|
+
if (!hasPlaythrough || !hasInput) {
|
|
142
208
|
issues.push(
|
|
143
|
-
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
const entryScene = scenes[0];
|
|
147
|
-
for (const scene of scenes.slice(1)) {
|
|
148
|
-
const transitioned = options.contracts.some(
|
|
149
|
-
(contract) => (contract.kind === "transition" || contract.kind === "playthrough") && normalizeList(contract.requireTransition).includes(scene)
|
|
209
|
+
"\u4EA4\u4E92\u9879\u76EE\u7F3A\u5C11\u6700\u4F4E\u73A9\u6CD5\u57FA\u7EBF\uFF1A\u81F3\u5C11\u4E00\u4E2A playthrough contract \u5FC5\u987B\u58F0\u660E requireFrameAdvance \u548C checkpoint\uFF0C\u4E14\u81F3\u5C11\u4E00\u4E2A contract \u5FC5\u987B\u58F0\u660E requireInput: true\u3002"
|
|
150
210
|
);
|
|
151
|
-
if (!transitioned) issues.push(`\u975E\u5165\u53E3 Scene "${scene}" \u5FC5\u987B\u7531 transition/playthrough contract \u901A\u8FC7\u751F\u4EA7\u8F6C\u573A\u8986\u76D6\u3002`);
|
|
152
|
-
}
|
|
153
|
-
if (entryScene && scenes.length > 1 && (kinds.get("transition") ?? []).length === 0 && (kinds.get("playthrough") ?? []).length === 0 && invalidKindContracts.length === 0) {
|
|
154
|
-
issues.push(`\u4EA4\u4E92\u9879\u76EE\u5165\u53E3 Scene "${entryScene}" \u7F3A\u5C11 transition/playthrough flow\u3002`);
|
|
155
211
|
}
|
|
156
212
|
const restartSignal = projectRoot2 ? containsProductionRestart(projectRoot2) : false;
|
|
157
213
|
if (restartSignal && options.flows?.restart !== true) {
|
|
@@ -165,7 +221,7 @@ function validateGameplayAuditOptions(options, projectRoot2) {
|
|
|
165
221
|
return issues;
|
|
166
222
|
}
|
|
167
223
|
function containsProductionRestart(projectRoot2) {
|
|
168
|
-
const sceneRoot =
|
|
224
|
+
const sceneRoot = join2(projectRoot2, "src", "scenes");
|
|
169
225
|
const visit = (directory) => {
|
|
170
226
|
let entries;
|
|
171
227
|
try {
|
|
@@ -174,7 +230,7 @@ function containsProductionRestart(projectRoot2) {
|
|
|
174
230
|
return false;
|
|
175
231
|
}
|
|
176
232
|
for (const entry of entries) {
|
|
177
|
-
const path =
|
|
233
|
+
const path = join2(directory, entry);
|
|
178
234
|
let stats;
|
|
179
235
|
try {
|
|
180
236
|
stats = statSync(path);
|
|
@@ -213,11 +269,21 @@ function getDefinitionIssues(expected, actual) {
|
|
|
213
269
|
}
|
|
214
270
|
function auditGameplayCollection(options, file, tests) {
|
|
215
271
|
const normalizedFile = file.replaceAll("\\", "/");
|
|
216
|
-
const expected = options.contracts.filter(
|
|
272
|
+
const expected = (options.contracts ?? []).filter(
|
|
217
273
|
(contract) => contract.testFile.replaceAll("\\", "/") === normalizedFile
|
|
218
274
|
);
|
|
219
275
|
const actual = tests.filter((test) => test.metadata);
|
|
220
276
|
const issues = [];
|
|
277
|
+
if (!options.contracts) {
|
|
278
|
+
const actualIds = /* @__PURE__ */ new Set();
|
|
279
|
+
for (const test of actual) {
|
|
280
|
+
const id = test.metadata?.id;
|
|
281
|
+
if (!id) continue;
|
|
282
|
+
if (actualIds.has(id)) issues.push(`contract "${id}" \u5728 ${normalizedFile} \u4E2D\u91CD\u590D\u5B9A\u4E49\u3002`);
|
|
283
|
+
actualIds.add(id);
|
|
284
|
+
}
|
|
285
|
+
return { passed: issues.length === 0, issues };
|
|
286
|
+
}
|
|
221
287
|
for (const contract of expected) {
|
|
222
288
|
const matches = actual.filter((test) => test.metadata?.id === contract.id);
|
|
223
289
|
if (matches.length === 0) {
|
|
@@ -290,7 +356,25 @@ function auditGameplayRun(options, tests) {
|
|
|
290
356
|
const issues = [];
|
|
291
357
|
const actualContracts = tests.filter((test) => test.metadata);
|
|
292
358
|
const observedEvidence = [];
|
|
293
|
-
|
|
359
|
+
const discoveredContracts = actualContracts.flatMap((test) => {
|
|
360
|
+
const metadata = test.metadata;
|
|
361
|
+
if (!metadata) return [];
|
|
362
|
+
return [{
|
|
363
|
+
id: metadata.id,
|
|
364
|
+
kind: metadata.kind,
|
|
365
|
+
testFile: test.file,
|
|
366
|
+
scenes: metadata.scenes,
|
|
367
|
+
...metadata.requirements
|
|
368
|
+
}];
|
|
369
|
+
});
|
|
370
|
+
const expectedContracts = options.contracts ?? discoveredContracts;
|
|
371
|
+
if (!options.contracts) {
|
|
372
|
+
const discoveredIssues = validateGameplayAuditOptions(
|
|
373
|
+
{ ...options, contracts: discoveredContracts }
|
|
374
|
+
);
|
|
375
|
+
issues.push(...discoveredIssues);
|
|
376
|
+
}
|
|
377
|
+
for (const expected of expectedContracts) {
|
|
294
378
|
const matches = actualContracts.filter(
|
|
295
379
|
(test2) => test2.metadata?.id === expected.id
|
|
296
380
|
);
|
|
@@ -333,7 +417,7 @@ function auditGameplayRun(options, tests) {
|
|
|
333
417
|
}
|
|
334
418
|
for (const test of actualContracts) {
|
|
335
419
|
const id = test.metadata?.id;
|
|
336
|
-
if (id && !options.contracts.some((contract) => contract.id === id)) {
|
|
420
|
+
if (id && options.contracts && !options.contracts.some((contract) => contract.id === id)) {
|
|
337
421
|
issues.push(
|
|
338
422
|
`\u53D1\u73B0\u672A\u5217\u5165 gameplayAudit \u6E05\u5355\u7684 contract "${id}"\uFF08${test.file}\uFF09\u3002`
|
|
339
423
|
);
|
|
@@ -528,7 +612,7 @@ var GameplayAuditReporter = class {
|
|
|
528
612
|
(specification) => normalizePath(relative(this.projectRoot, specification.moduleId))
|
|
529
613
|
)
|
|
530
614
|
);
|
|
531
|
-
for (const contract of this.options.contracts) {
|
|
615
|
+
for (const contract of this.options.contracts ?? []) {
|
|
532
616
|
const testFile = normalizePath(contract.testFile);
|
|
533
617
|
if (!scheduledFiles.has(testFile)) {
|
|
534
618
|
this.reportPreflightIssue(
|
|
@@ -572,7 +656,7 @@ var GameplayAuditReporter = class {
|
|
|
572
656
|
} else {
|
|
573
657
|
console.log(
|
|
574
658
|
`
|
|
575
|
-
GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${
|
|
659
|
+
GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${tests.filter((test) => test.metadata).length} contracts, ${this.options.scenes.length} scenes)`
|
|
576
660
|
);
|
|
577
661
|
console.log(
|
|
578
662
|
"GAMEPLAY_AUDIT_NOTE: \u73A9\u6CD5\u68C0\u67E5\u5DF2\u901A\u8FC7\uFF1B\u4ECD\u9700\u4EE5\u6700\u540E\u7684 TEST_RESULT \u5224\u65AD\u6574\u4F53\u6210\u529F\u3002"
|
|
@@ -597,10 +681,11 @@ function defineGameVitestConfig(options) {
|
|
|
597
681
|
);
|
|
598
682
|
}
|
|
599
683
|
return defineConfig({
|
|
684
|
+
plugins: [phaserRexUIScenePlugin(options.projectRoot)],
|
|
600
685
|
resolve: {
|
|
601
686
|
alias: {
|
|
602
687
|
...options.aliases,
|
|
603
|
-
"@":
|
|
688
|
+
"@": resolve2(options.projectRoot, "src")
|
|
604
689
|
}
|
|
605
690
|
},
|
|
606
691
|
// devkit 作为 external ESM 加载时,应用测试也必须 externalize Phaser,
|
|
@@ -660,8 +745,93 @@ function defineGameVitestConfig(options) {
|
|
|
660
745
|
});
|
|
661
746
|
}
|
|
662
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
|
+
|
|
663
833
|
// src/lint/vitest-config.test.ts
|
|
664
|
-
var projectRoot =
|
|
834
|
+
var projectRoot = resolve4(import.meta.dirname, "../..");
|
|
665
835
|
var gameplayAudit = {
|
|
666
836
|
mode: "non-interactive",
|
|
667
837
|
nonInteractiveJustification: "\u56FA\u5B9A\u5939\u5177\u53EA\u9A8C\u8BC1\u5171\u4EAB host \u57FA\u7EBF\uFF0C\u4E0D\u5305\u542B\u73A9\u6CD5\u8F93\u5165\u3002",
|
|
@@ -677,11 +847,29 @@ var gameplayAudit = {
|
|
|
677
847
|
]
|
|
678
848
|
};
|
|
679
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
|
+
});
|
|
680
865
|
it("\u63D0\u4F9B Phaser \u8FD0\u884C\u65F6\u6D4B\u8BD5\u9700\u8981\u7684\u56FA\u5B9A\u57FA\u7EBF", () => {
|
|
681
866
|
const config = defineGameVitestConfig({ projectRoot, gameplayAudit });
|
|
682
867
|
expect(config.resolve?.alias).toMatchObject({
|
|
683
|
-
"@":
|
|
868
|
+
"@": resolve4(projectRoot, "src")
|
|
684
869
|
});
|
|
870
|
+
expect(config.plugins).toEqual([
|
|
871
|
+
expect.objectContaining({ name: "miaoda-phaser-rexui-scene" })
|
|
872
|
+
]);
|
|
685
873
|
expect(config.resolve?.alias).not.toHaveProperty("phaser");
|
|
686
874
|
expect(config.ssr).toMatchObject({ external: ["phaser"] });
|
|
687
875
|
expect(config.test).toMatchObject({
|
|
@@ -755,3 +943,40 @@ describe("defineGameVitestConfig", () => {
|
|
|
755
943
|
).toThrow(/缺少最低玩法基线/);
|
|
756
944
|
});
|
|
757
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 };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/react/index.ts
|
|
21
|
+
var react_exports = {};
|
|
22
|
+
__export(react_exports, {
|
|
23
|
+
browserGameClock: () => browserGameClock,
|
|
24
|
+
useOwnedGameSession: () => useOwnedGameSession
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(react_exports);
|
|
27
|
+
|
|
28
|
+
// src/react/game-clock.ts
|
|
29
|
+
var browserGameClock = {
|
|
30
|
+
now: () => performance.now(),
|
|
31
|
+
requestFrame: (callback) => requestAnimationFrame(callback),
|
|
32
|
+
cancelFrame: (id) => cancelAnimationFrame(id),
|
|
33
|
+
setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
|
|
34
|
+
clearTimeout: (id) => window.clearTimeout(id)
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/react/use-owned-game-session.ts
|
|
38
|
+
var import_react = require("react");
|
|
39
|
+
function useOwnedGameSession(createSession) {
|
|
40
|
+
const [session] = (0, import_react.useState)(createSession);
|
|
41
|
+
const mounted = (0, import_react.useRef)(false);
|
|
42
|
+
(0, import_react.useEffect)(() => {
|
|
43
|
+
mounted.current = true;
|
|
44
|
+
return () => {
|
|
45
|
+
mounted.current = false;
|
|
46
|
+
queueMicrotask(() => {
|
|
47
|
+
if (!mounted.current) session.destroy();
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
}, [session]);
|
|
51
|
+
return session;
|
|
52
|
+
}
|
|
53
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
54
|
+
0 && (module.exports = {
|
|
55
|
+
browserGameClock,
|
|
56
|
+
useOwnedGameSession
|
|
57
|
+
});
|