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.
Files changed (40) hide show
  1. package/README.md +39 -13
  2. package/dist/cli/lint.js +32 -1
  3. package/dist/game-clock-suUidZdT.d.mts +11 -0
  4. package/dist/game-clock-suUidZdT.d.ts +11 -0
  5. package/dist/{gameplay-audit-CusQfLYQ.d.mts → gameplay-audit-BwAobjIX.d.mts} +3 -3
  6. package/dist/{gameplay-audit-CusQfLYQ.d.ts → gameplay-audit-BwAobjIX.d.ts} +3 -3
  7. package/dist/index.d.mts +26 -3
  8. package/dist/index.d.ts +26 -3
  9. package/dist/index.js +52 -0
  10. package/dist/index.mjs +49 -0
  11. package/dist/lint/game-telemetry.contract.mjs +1241 -0
  12. package/dist/lint/gameplay-audit.contract.mjs +111 -46
  13. package/dist/lint/gameplay-contract.contract.mjs +5 -2
  14. package/dist/lint/vitest-config.contract.mjs +262 -37
  15. package/dist/phaser-facade.mjs +156 -0
  16. package/dist/react/index.d.mts +13 -0
  17. package/dist/react/index.d.ts +13 -0
  18. package/dist/react/index.js +57 -0
  19. package/dist/react/index.mjs +29 -0
  20. package/dist/react/testing.d.mts +21 -0
  21. package/dist/react/testing.d.ts +21 -0
  22. package/dist/react/testing.js +82 -0
  23. package/dist/react/testing.mjs +57 -0
  24. package/dist/react/vitest-config.d.mts +14 -0
  25. package/dist/react/vitest-config.d.ts +14 -0
  26. package/dist/react/vitest-config.js +57 -0
  27. package/dist/react/vitest-config.mjs +32 -0
  28. package/dist/react/vitest-setup.d.mts +2 -0
  29. package/dist/react/vitest-setup.d.ts +2 -0
  30. package/dist/react/vitest-setup.js +35 -0
  31. package/dist/react/vitest-setup.mjs +33 -0
  32. package/dist/vite.d.mts +22 -0
  33. package/dist/vite.d.ts +22 -0
  34. package/dist/vite.js +93 -0
  35. package/dist/vite.mjs +67 -0
  36. package/dist/vitest-config.d.mts +2 -2
  37. package/dist/vitest-config.d.ts +2 -2
  38. package/dist/vitest-config.js +95 -31
  39. package/dist/vitest-config.mjs +95 -31
  40. package/package.json +72 -2
@@ -1,14 +1,78 @@
1
1
  // src/lint/vitest-config.test.ts
2
- import { resolve as resolve2 } from "path";
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 options.contracts) {
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
- for (const scene of scenes) {
131
- if (!coveredScenes.has(scene)) {
132
- issues.push(`Scene "${scene}" \u6CA1\u6709\u4EFB\u4F55 gameplay contract \u8D1F\u8D23\u9A8C\u8BC1\u3002`);
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 (options.contracts.length === 0)
136
- issues.push("gameplayAudit.contracts \u4E0D\u80FD\u4E3A\u7A7A\u3002");
137
- if (mode === "interactive") {
138
- const hasPlaythrough = (kinds.get("playthrough") ?? []).length > 0;
139
- const hasInput = options.contracts.some((contract) => contract.requireInput);
140
- const hasCheckpoint = options.contracts.some((contract) => normalizeList(contract.requireCheckpoint).length > 0);
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
- '\u4EA4\u4E92\u9879\u76EE\u7F3A\u5C11\u6700\u4F4E\u73A9\u6CD5\u57FA\u7EBF\uFF1A\u81F3\u5C11\u4E00\u4E2A kind: "playthrough" contract \u5FC5\u987B\u540C\u65F6\u58F0\u660E requireInput: true\u3001requireFrameAdvance: true \u548C requireCheckpoint: ["progress"]\u3002'
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 = join(projectRoot2, "src", "scenes");
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 = join(directory, entry);
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
- for (const expected of options.contracts) {
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 (${this.options.contracts.length} contracts, ${this.options.scenes.length} scenes)`
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
- "@": resolve(options.projectRoot, "src")
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 = resolve2(import.meta.dirname, "../..");
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
- "@": resolve2(projectRoot, "src")
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
+ });