miaoda-game-devkit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,9 @@ function isEffectivelyRenderable(label) {
25
25
  }
26
26
  function requireFinite(problems, label, property, value) {
27
27
  if (!Number.isFinite(value)) {
28
- problems.push(`${describeText(label)} has non-finite ${property}: ${String(value)}`);
28
+ problems.push(
29
+ `${describeText(label)} has non-finite ${property}: ${String(value)}`
30
+ );
29
31
  }
30
32
  }
31
33
  function collectSceneTexts(scene) {
@@ -79,7 +81,9 @@ function collectMissingBitmapGlyphs(text, chars) {
79
81
  const code = text.charCodeAt(index);
80
82
  if (seen.has(code) || chars[code] !== void 0) continue;
81
83
  seen.add(code);
82
- missing.push(`${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`);
84
+ missing.push(
85
+ `${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
86
+ );
83
87
  }
84
88
  return missing;
85
89
  }
@@ -104,19 +108,29 @@ function collectTextHealthProblems(scene) {
104
108
  requireFinite(problems, label, "resolution", label.style.resolution);
105
109
  const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
106
110
  if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
107
- problems.push(`${describeText(label)} has non-positive width: ${label.width}`);
111
+ problems.push(
112
+ `${describeText(label)} has non-positive width: ${label.width}`
113
+ );
108
114
  }
109
115
  if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
110
- problems.push(`${describeText(label)} has non-positive height: ${label.height}`);
116
+ problems.push(
117
+ `${describeText(label)} has non-positive height: ${label.height}`
118
+ );
111
119
  }
112
120
  if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
113
- problems.push(`${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`);
121
+ problems.push(
122
+ `${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`
123
+ );
114
124
  }
115
125
  if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
116
- problems.push(`${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`);
126
+ problems.push(
127
+ `${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`
128
+ );
117
129
  }
118
130
  if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
119
- problems.push(`${describeText(label)} has non-positive resolution: ${label.style.resolution}`);
131
+ problems.push(
132
+ `${describeText(label)} has non-positive resolution: ${label.style.resolution}`
133
+ );
120
134
  }
121
135
  const bounds = label.getBounds();
122
136
  for (const [property, value] of Object.entries({
@@ -150,11 +164,13 @@ function assertSceneTextHealth(scene) {
150
164
  ...collectBitmapTextHealthProblems(scene)
151
165
  ];
152
166
  if (problems.length === 0) return;
153
- throw new Error([
154
- `Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
155
- ...problems.map((problem) => `- ${problem}`),
156
- "Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
157
- ].join("\n"));
167
+ throw new Error(
168
+ [
169
+ `Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
170
+ ...problems.map((problem) => `- ${problem}`),
171
+ "Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
172
+ ].join("\n")
173
+ );
158
174
  }
159
175
 
160
176
  // src/phaser-runtime-assertions.ts
@@ -166,8 +182,10 @@ function collectObjects(scene) {
166
182
  if (visited.has(object)) return;
167
183
  visited.add(object);
168
184
  objects.push(object);
169
- if (object instanceof Phaser2.GameObjects.Container) object.list.forEach(visit);
170
- if (object instanceof Phaser2.GameObjects.Layer) object.getChildren().forEach(visit);
185
+ if (object instanceof Phaser2.GameObjects.Container)
186
+ object.list.forEach(visit);
187
+ if (object instanceof Phaser2.GameObjects.Layer)
188
+ object.getChildren().forEach(visit);
171
189
  };
172
190
  scene.children.getChildren().forEach(visit);
173
191
  return objects;
@@ -201,7 +219,9 @@ function requireFiniteVector(problems, subject, target, property) {
201
219
  if (!(axis in vector)) continue;
202
220
  const value = Reflect.get(vector, axis);
203
221
  if (typeof value !== "number" || !Number.isFinite(value)) {
204
- problems.push(`${subject} has non-finite ${property}.${axis}: ${String(value)}`);
222
+ problems.push(
223
+ `${subject} has non-finite ${property}.${axis}: ${String(value)}`
224
+ );
205
225
  }
206
226
  }
207
227
  }
@@ -269,7 +289,9 @@ function getArcadeBodyCollection(value) {
269
289
  function getArcadeWorldBodies(world) {
270
290
  if (!world || typeof world !== "object") return void 0;
271
291
  const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
272
- const staticBodies = getArcadeBodyCollection(Reflect.get(world, "staticBodies"));
292
+ const staticBodies = getArcadeBodyCollection(
293
+ Reflect.get(world, "staticBodies")
294
+ );
273
295
  return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
274
296
  }
275
297
  function collectArcadeBodyHealthProblems(scene) {
@@ -280,22 +302,38 @@ function collectArcadeBodyHealthProblems(scene) {
280
302
  if (body.enable === false) continue;
281
303
  const gameObject = body.gameObject;
282
304
  const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
283
- for (const property of ["position", "velocity", "acceleration", "gravity", "offset", "center"]) {
305
+ for (const property of [
306
+ "position",
307
+ "velocity",
308
+ "acceleration",
309
+ "gravity",
310
+ "offset",
311
+ "center"
312
+ ]) {
284
313
  requireFiniteVector(problems, subject, body, property);
285
314
  }
286
- for (const property of ["width", "height", "halfWidth", "halfHeight", "rotation"]) {
315
+ for (const property of [
316
+ "width",
317
+ "height",
318
+ "halfWidth",
319
+ "halfHeight",
320
+ "rotation"
321
+ ]) {
287
322
  requireFiniteProperty(problems, subject, body, property);
288
323
  }
289
324
  }
290
325
  return problems;
291
326
  }
292
327
  function usesCustomHitArea(object) {
293
- return Boolean(object.input?.customHitArea);
328
+ return Boolean(
329
+ object.input?.customHitArea
330
+ );
294
331
  }
295
332
  function collectInteractiveHealthProblems(scene) {
296
333
  const problems = [];
297
334
  for (const object of collectObjects(scene)) {
298
- if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled) continue;
335
+ if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled)
336
+ continue;
299
337
  if (usesCustomHitArea(object)) continue;
300
338
  const hitArea = object.input.hitArea;
301
339
  if (!hitArea) {
@@ -318,20 +356,24 @@ function assertSceneRuntimeHealth(scene) {
318
356
  ...collectArcadeBodyHealthProblems(scene)
319
357
  ];
320
358
  if (problems.length === 0) return;
321
- throw new Error([
322
- `Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
323
- ...problems.map((problem) => `- ${problem}`),
324
- "Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
325
- ].join("\n"));
359
+ throw new Error(
360
+ [
361
+ `Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
362
+ ...problems.map((problem) => `- ${problem}`),
363
+ "Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
364
+ ].join("\n")
365
+ );
326
366
  }
327
367
  function assertSceneInteractiveHealth(scene) {
328
368
  const problems = collectInteractiveHealthProblems(scene);
329
369
  if (problems.length === 0) return;
330
- throw new Error([
331
- `Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
332
- ...problems.map((problem) => `- ${problem}`),
333
- "Fix the default hit area or disable input before making the object interactive."
334
- ].join("\n"));
370
+ throw new Error(
371
+ [
372
+ `Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
373
+ ...problems.map((problem) => `- ${problem}`),
374
+ "Fix the default hit area or disable input before making the object interactive."
375
+ ].join("\n")
376
+ );
335
377
  }
336
378
 
337
379
  // src/phaser-headless-host.ts
@@ -346,11 +388,32 @@ async function createHeadlessGame(scene, options = {}) {
346
388
  let removeRuntimeListeners = () => {
347
389
  };
348
390
  const transitions = [];
391
+ const registeredScenes = [];
392
+ const visitedScenes = [];
393
+ const restartedScenes = [];
394
+ const checkpoints = [];
395
+ const appendUnique = (values, value) => {
396
+ if (value && !values.includes(value)) values.push(value);
397
+ };
398
+ const assertStepCount = (value, name) => {
399
+ if (!Number.isSafeInteger(value) || value < 0) {
400
+ throw new Error(
401
+ `${name} must be a non-negative safe integer; received ${value}`
402
+ );
403
+ }
404
+ };
349
405
  const evidence = {
350
406
  frames: 0,
351
407
  physicsSteps: 0,
352
408
  mouseEvents: 0,
409
+ keyboardEvents: 0,
410
+ touchEvents: 0,
353
411
  clicks: 0,
412
+ registeredScenes,
413
+ visitedScenes,
414
+ restartedScenes,
415
+ checkpoints,
416
+ destroyed: false,
354
417
  transitions
355
418
  };
356
419
  const created = new Promise((resolve, reject) => {
@@ -396,7 +459,9 @@ async function createHeadlessGame(scene, options = {}) {
396
459
  });
397
460
  };
398
461
  const timeout = window.setTimeout(() => {
399
- finish(new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`));
462
+ finish(
463
+ new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`)
464
+ );
400
465
  }, bootTimeoutMs);
401
466
  window.addEventListener("error", onWindowError, true);
402
467
  window.addEventListener("unhandledrejection", onUnhandledRejection);
@@ -408,7 +473,13 @@ async function createHeadlessGame(scene, options = {}) {
408
473
  if (guardedScenes.has(currentScene)) return;
409
474
  guardedScenes.add(currentScene);
410
475
  const scenePlugin = currentScene.scene;
411
- for (const method of ["start", "launch", "switch", "sleep", "wake"]) {
476
+ for (const method of [
477
+ "start",
478
+ "launch",
479
+ "switch",
480
+ "sleep",
481
+ "wake"
482
+ ]) {
412
483
  const original = scenePlugin[method];
413
484
  if (typeof original !== "function") continue;
414
485
  scenePlugin[method] = (key, ...args) => {
@@ -424,29 +495,49 @@ async function createHeadlessGame(scene, options = {}) {
424
495
  scenePlugin[method] = original;
425
496
  });
426
497
  }
498
+ const originalRestart = scenePlugin.restart;
499
+ if (typeof originalRestart === "function") {
500
+ scenePlugin.restart = (...args) => {
501
+ appendUnique(restartedScenes, currentScene.sys.settings.key);
502
+ return Reflect.apply(originalRestart, currentScene.scene, args);
503
+ };
504
+ restoreGuards.push(() => {
505
+ scenePlugin.restart = originalRestart;
506
+ });
507
+ }
427
508
  for (const hook of ["init", "preload", "create", "update"]) {
428
509
  const original = Reflect.get(currentScene, hook);
429
510
  if (typeof original !== "function") continue;
430
- const ownDescriptor = Object.getOwnPropertyDescriptor(currentScene, hook);
431
- Reflect.set(currentScene, hook, function guardedLifecycleHook(...args) {
432
- try {
433
- const result = Reflect.apply(original, this, args);
434
- if (result && typeof result === "object" && "then" in result) {
435
- const completion = Promise.resolve(result);
436
- if (currentScene === scene && hook === "create" && !settled) {
437
- completion.then(() => window.setTimeout(() => finish(), 0), reportError);
438
- } else {
439
- completion.catch(reportError);
511
+ const ownDescriptor = Object.getOwnPropertyDescriptor(
512
+ currentScene,
513
+ hook
514
+ );
515
+ Reflect.set(
516
+ currentScene,
517
+ hook,
518
+ function guardedLifecycleHook(...args) {
519
+ try {
520
+ const result = Reflect.apply(original, this, args);
521
+ if (result && typeof result === "object" && "then" in result) {
522
+ const completion = Promise.resolve(result);
523
+ if (currentScene === scene && hook === "create" && !settled) {
524
+ completion.then(
525
+ () => window.setTimeout(() => finish(), 0),
526
+ reportError
527
+ );
528
+ } else {
529
+ completion.catch(reportError);
530
+ }
531
+ } else if (currentScene === scene && hook === "create" && !settled) {
532
+ window.setTimeout(() => finish(), 0);
440
533
  }
441
- } else if (currentScene === scene && hook === "create" && !settled) {
442
- window.setTimeout(() => finish(), 0);
534
+ return result;
535
+ } catch (error) {
536
+ reportError(error);
537
+ return void 0;
443
538
  }
444
- return result;
445
- } catch (error) {
446
- reportError(error);
447
- return void 0;
448
539
  }
449
- });
540
+ );
450
541
  restoreGuards.push(() => {
451
542
  if (ownDescriptor) {
452
543
  Object.defineProperty(currentScene, hook, ownDescriptor);
@@ -475,13 +566,6 @@ async function createHeadlessGame(scene, options = {}) {
475
566
  banner: false,
476
567
  autoFocus: false,
477
568
  seed: ["phaser-headless-test"],
478
- physics: {
479
- default: "arcade",
480
- arcade: {
481
- gravity: { x: 0, y: 0 },
482
- fixedStep: true
483
- }
484
- },
485
569
  ...config,
486
570
  type: Phaser3.HEADLESS,
487
571
  fps,
@@ -507,7 +591,10 @@ async function createHeadlessGame(scene, options = {}) {
507
591
  };
508
592
  bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
509
593
  removeReadinessCheck = () => {
510
- bootedGame.events.off(Phaser3.Core.Events.POST_STEP, checkReadiness);
594
+ bootedGame.events.off(
595
+ Phaser3.Core.Events.POST_STEP,
596
+ checkReadiness
597
+ );
511
598
  };
512
599
  }
513
600
  },
@@ -547,7 +634,9 @@ async function createHeadlessGame(scene, options = {}) {
547
634
  restoreHostGuards();
548
635
  readyGame.destroy(true);
549
636
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
550
- throw new Error("Phaser HEADLESS input canvas is not attached to a DOM Window");
637
+ throw new Error(
638
+ "Phaser HEADLESS input canvas is not attached to a DOM Window"
639
+ );
551
640
  }
552
641
  let canvasBounds = {
553
642
  left: 0,
@@ -583,7 +672,9 @@ async function createHeadlessGame(scene, options = {}) {
583
672
  setCanvasBounds(canvasBounds);
584
673
  const gameToClient = (x, y) => {
585
674
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
586
- throw new Error(`HEADLESS input coordinates must be finite; received (${x}, ${y})`);
675
+ throw new Error(
676
+ `HEADLESS input coordinates must be finite; received (${x}, ${y})`
677
+ );
587
678
  }
588
679
  return {
589
680
  clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
@@ -592,7 +683,10 @@ async function createHeadlessGame(scene, options = {}) {
592
683
  };
593
684
  const assertHealth = () => {
594
685
  for (const currentScene of readyGame.scene.getScenes(false)) {
595
- if (!currentScene.sys.isActive() && !currentScene.sys.isPaused()) continue;
686
+ appendUnique(registeredScenes, currentScene.sys.settings.key);
687
+ if (!currentScene.sys.isActive() && !currentScene.sys.isPaused())
688
+ continue;
689
+ appendUnique(visitedScenes, currentScene.sys.settings.key);
596
690
  assertSceneTextHealth(currentScene);
597
691
  assertSceneInteractiveHealth(currentScene);
598
692
  assertSceneRuntimeHealth(currentScene);
@@ -603,17 +697,166 @@ async function createHeadlessGame(scene, options = {}) {
603
697
  throwRuntimeError();
604
698
  assertHealth();
605
699
  };
700
+ const getEventTarget = (target, kind) => {
701
+ if (!target || typeof Reflect.get(target, "dispatchEvent") !== "function") {
702
+ throw new Error(
703
+ `Phaser HEADLESS ${kind} input target cannot dispatch DOM events`
704
+ );
705
+ }
706
+ return target;
707
+ };
606
708
  const dispatchMouse = async (type, x, y, buttons) => {
607
709
  const { clientX, clientY } = gameToClient(x, y);
608
- canvas.dispatchEvent(new inputWindow.MouseEvent(type, {
710
+ const mouseTarget = getEventTarget(readyGame.input.mouse?.target, "mouse");
711
+ mouseTarget.dispatchEvent(
712
+ new inputWindow.MouseEvent(type, {
713
+ bubbles: true,
714
+ cancelable: true,
715
+ button: 0,
716
+ buttons,
717
+ clientX,
718
+ clientY
719
+ })
720
+ );
721
+ evidence.mouseEvents += 1;
722
+ await settleRuntime();
723
+ };
724
+ const keyIdentity = (keyCode) => {
725
+ if (keyCode >= 65 && keyCode <= 90) {
726
+ const letter = String.fromCharCode(keyCode);
727
+ return { key: letter.toLowerCase(), code: `Key${letter}` };
728
+ }
729
+ if (keyCode >= 48 && keyCode <= 57) {
730
+ const digit = String.fromCharCode(keyCode);
731
+ return { key: digit, code: `Digit${digit}` };
732
+ }
733
+ const identities = /* @__PURE__ */ new Map([
734
+ [
735
+ Phaser3.Input.Keyboard.KeyCodes.LEFT,
736
+ { key: "ArrowLeft", code: "ArrowLeft" }
737
+ ],
738
+ [
739
+ Phaser3.Input.Keyboard.KeyCodes.RIGHT,
740
+ { key: "ArrowRight", code: "ArrowRight" }
741
+ ],
742
+ [Phaser3.Input.Keyboard.KeyCodes.UP, { key: "ArrowUp", code: "ArrowUp" }],
743
+ [
744
+ Phaser3.Input.Keyboard.KeyCodes.DOWN,
745
+ { key: "ArrowDown", code: "ArrowDown" }
746
+ ],
747
+ [Phaser3.Input.Keyboard.KeyCodes.SPACE, { key: " ", code: "Space" }],
748
+ [Phaser3.Input.Keyboard.KeyCodes.ENTER, { key: "Enter", code: "Enter" }],
749
+ [Phaser3.Input.Keyboard.KeyCodes.ESC, { key: "Escape", code: "Escape" }]
750
+ ]);
751
+ return identities.get(keyCode) ?? { key: "", code: "" };
752
+ };
753
+ const dispatchKeyboard = async (type, keyCode, options2 = {}) => {
754
+ if (!Number.isInteger(keyCode) || keyCode < 0) {
755
+ throw new Error(
756
+ `HEADLESS keyboard keyCode must be a non-negative integer; received ${keyCode}`
757
+ );
758
+ }
759
+ const identity = keyIdentity(keyCode);
760
+ const event = new inputWindow.KeyboardEvent(type, {
609
761
  bubbles: true,
610
762
  cancelable: true,
611
- button: 0,
612
- buttons,
763
+ key: options2.key ?? identity.key,
764
+ code: options2.code ?? identity.code,
765
+ repeat: options2.repeat ?? false,
766
+ altKey: options2.altKey ?? false,
767
+ ctrlKey: options2.ctrlKey ?? false,
768
+ metaKey: options2.metaKey ?? false,
769
+ shiftKey: options2.shiftKey ?? false
770
+ });
771
+ Object.defineProperties(event, {
772
+ // Phaser KeyboardManager 仍使用这些兼容旧浏览器的数字字段。
773
+ keyCode: { value: keyCode },
774
+ which: { value: keyCode }
775
+ });
776
+ const keyboardTarget = getEventTarget(
777
+ readyGame.input.keyboard?.target,
778
+ "keyboard"
779
+ );
780
+ keyboardTarget.dispatchEvent(event);
781
+ evidence.keyboardEvents += 1;
782
+ await settleRuntime();
783
+ };
784
+ const activeTouches = /* @__PURE__ */ new Map();
785
+ const createTouch = (x, y, identifier, target) => {
786
+ if (!Number.isInteger(identifier) || identifier < 0) {
787
+ throw new Error(
788
+ `HEADLESS touch identifier must be a non-negative integer; received ${identifier}`
789
+ );
790
+ }
791
+ const { clientX, clientY } = gameToClient(x, y);
792
+ return {
793
+ identifier,
794
+ target,
613
795
  clientX,
614
- clientY
615
- }));
616
- evidence.mouseEvents += 1;
796
+ clientY,
797
+ pageX: clientX,
798
+ pageY: clientY,
799
+ screenX: clientX,
800
+ screenY: clientY,
801
+ radiusX: 1,
802
+ radiusY: 1,
803
+ rotationAngle: 0,
804
+ force: 1
805
+ };
806
+ };
807
+ const dispatchTouch = async (type, x, y, identifier) => {
808
+ if (type === "touchstart" && activeTouches.has(identifier)) {
809
+ throw new Error(
810
+ `HEADLESS touch identifier ${identifier} is already active`
811
+ );
812
+ }
813
+ if (type !== "touchstart" && !activeTouches.has(identifier)) {
814
+ throw new Error(`HEADLESS touch identifier ${identifier} is not active`);
815
+ }
816
+ const touchTarget = getEventTarget(readyGame.input.touch?.target, "touch");
817
+ const changedTouch = createTouch(x, y, identifier, touchTarget);
818
+ if (type === "touchstart" || type === "touchmove") {
819
+ activeTouches.set(identifier, changedTouch);
820
+ } else {
821
+ activeTouches.delete(identifier);
822
+ }
823
+ const touches = [...activeTouches.values()];
824
+ const event = new inputWindow.Event(type, {
825
+ bubbles: true,
826
+ cancelable: true
827
+ });
828
+ Object.defineProperties(event, {
829
+ changedTouches: { value: [changedTouch] },
830
+ targetTouches: { value: touches },
831
+ touches: { value: touches }
832
+ });
833
+ const document = inputWindow.document;
834
+ const originalElementFromPoint = document.elementFromPoint;
835
+ const originalElementFromPointDescriptor = Object.getOwnPropertyDescriptor(
836
+ document,
837
+ "elementFromPoint"
838
+ );
839
+ Object.defineProperty(document, "elementFromPoint", {
840
+ configurable: true,
841
+ value(clientX, clientY) {
842
+ const insideCanvas = clientX >= canvasBounds.left && clientX <= canvasBounds.left + canvasBounds.width && clientY >= canvasBounds.top && clientY <= canvasBounds.top + canvasBounds.height;
843
+ return insideCanvas ? canvas : originalElementFromPoint?.call(document, clientX, clientY) ?? null;
844
+ }
845
+ });
846
+ try {
847
+ touchTarget.dispatchEvent(event);
848
+ evidence.touchEvents += 1;
849
+ } finally {
850
+ if (originalElementFromPointDescriptor) {
851
+ Object.defineProperty(
852
+ document,
853
+ "elementFromPoint",
854
+ originalElementFromPointDescriptor
855
+ );
856
+ } else {
857
+ Reflect.deleteProperty(document, "elementFromPoint");
858
+ }
859
+ }
617
860
  await settleRuntime();
618
861
  };
619
862
  const input = {
@@ -631,6 +874,24 @@ async function createHeadlessGame(scene, options = {}) {
631
874
  await dispatchMouse("mousedown", x, y, 1);
632
875
  await dispatchMouse("mouseup", x, y, 0);
633
876
  }
877
+ },
878
+ keyboard: {
879
+ down: (keyCode, options2) => dispatchKeyboard("keydown", keyCode, options2),
880
+ up: (keyCode, options2) => dispatchKeyboard("keyup", keyCode, options2),
881
+ async press(keyCode, options2) {
882
+ await dispatchKeyboard("keydown", keyCode, options2);
883
+ await dispatchKeyboard("keyup", keyCode, options2);
884
+ }
885
+ },
886
+ touch: {
887
+ start: (x, y, identifier = 1) => dispatchTouch("touchstart", x, y, identifier),
888
+ move: (x, y, identifier = 1) => dispatchTouch("touchmove", x, y, identifier),
889
+ end: (x, y, identifier = 1) => dispatchTouch("touchend", x, y, identifier),
890
+ cancel: (x, y, identifier = 1) => dispatchTouch("touchcancel", x, y, identifier),
891
+ async tap(x, y, identifier = 1) {
892
+ await dispatchTouch("touchstart", x, y, identifier);
893
+ await dispatchTouch("touchend", x, y, identifier);
894
+ }
634
895
  }
635
896
  };
636
897
  const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
@@ -639,6 +900,7 @@ async function createHeadlessGame(scene, options = {}) {
639
900
  evidence.frames += 1;
640
901
  };
641
902
  const stepFrames = (count = 1) => {
903
+ assertStepCount(count, "stepFrames count");
642
904
  throwRuntimeError();
643
905
  for (let frame = 0; frame < count; frame += 1) stepFrame();
644
906
  throwRuntimeError();
@@ -662,21 +924,59 @@ async function createHeadlessGame(scene, options = {}) {
662
924
  input,
663
925
  evidence,
664
926
  assertGameplayEvidence(requirements = {}) {
665
- if (requirements.requireInput && evidence.mouseEvents === 0) {
666
- throw new Error("Gameplay evidence is missing real HEADLESS input events.");
927
+ const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
928
+ if (requirements.requireInput && inputEvents === 0) {
929
+ throw new Error(
930
+ "Gameplay evidence is missing real HEADLESS input events."
931
+ );
667
932
  }
668
933
  if (requirements.requireFrameAdvance && evidence.frames === 0) {
669
- throw new Error("Gameplay evidence is missing complete Phaser frame advancement.");
934
+ throw new Error(
935
+ "Gameplay evidence is missing complete Phaser frame advancement."
936
+ );
670
937
  }
671
938
  if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
672
- throw new Error("Gameplay evidence is missing Arcade Physics advancement.");
939
+ throw new Error(
940
+ "Gameplay evidence is missing Arcade Physics advancement."
941
+ );
673
942
  }
674
943
  const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
675
944
  for (const target of requiredTransitions) {
676
945
  if (!evidence.transitions.some((transition) => transition.to === target)) {
677
- throw new Error(`Gameplay evidence is missing a transition to ${target}.`);
946
+ throw new Error(
947
+ `Gameplay evidence is missing a transition to ${target}.`
948
+ );
949
+ }
950
+ }
951
+ const requiredScenes = requirements.requireScene === void 0 ? [] : typeof requirements.requireScene === "string" ? [requirements.requireScene] : requirements.requireScene;
952
+ for (const sceneKey of requiredScenes) {
953
+ if (!evidence.visitedScenes.includes(sceneKey)) {
954
+ throw new Error(
955
+ `Gameplay evidence is missing a visit to Scene ${sceneKey}.`
956
+ );
678
957
  }
679
958
  }
959
+ const requiredRestarts = requirements.requireRestart === void 0 ? [] : typeof requirements.requireRestart === "string" ? [requirements.requireRestart] : requirements.requireRestart;
960
+ for (const sceneKey of requiredRestarts) {
961
+ if (!evidence.restartedScenes.includes(sceneKey)) {
962
+ throw new Error(
963
+ `Gameplay evidence is missing a restart of Scene ${sceneKey}.`
964
+ );
965
+ }
966
+ }
967
+ const requiredCheckpoints = requirements.requireCheckpoint === void 0 ? [] : typeof requirements.requireCheckpoint === "string" ? [requirements.requireCheckpoint] : requirements.requireCheckpoint;
968
+ for (const checkpoint of requiredCheckpoints) {
969
+ if (!evidence.checkpoints.includes(checkpoint)) {
970
+ throw new Error(
971
+ `Gameplay evidence is missing checkpoint ${checkpoint}.`
972
+ );
973
+ }
974
+ }
975
+ if (requirements.requireDestroy && !evidence.destroyed) {
976
+ throw new Error(
977
+ "Gameplay evidence is missing HEADLESS host destruction."
978
+ );
979
+ }
680
980
  },
681
981
  stepFrames,
682
982
  async stepFramesAsync(count = 1) {
@@ -684,6 +984,7 @@ async function createHeadlessGame(scene, options = {}) {
684
984
  await settleRuntime();
685
985
  },
686
986
  stepUntil(condition, maxFrames = 120) {
987
+ assertStepCount(maxFrames, "stepUntil maxFrames");
687
988
  throwRuntimeError();
688
989
  for (let frame = 0; frame <= maxFrames; frame += 1) {
689
990
  if (condition()) {
@@ -696,13 +997,18 @@ async function createHeadlessGame(scene, options = {}) {
696
997
  }
697
998
  }
698
999
  assertHealth();
699
- throw new Error(`Condition was not met within ${maxFrames} complete Phaser frames`);
1000
+ throw new Error(
1001
+ `Condition was not met within ${maxFrames} complete Phaser frames`
1002
+ );
700
1003
  },
701
1004
  stepPhysics(steps = 1) {
1005
+ assertStepCount(steps, "stepPhysics steps");
702
1006
  throwRuntimeError();
703
1007
  const world = scene.physics?.world;
704
1008
  if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
705
- throw new Error("stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations.");
1009
+ throw new Error(
1010
+ "stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations."
1011
+ );
706
1012
  }
707
1013
  for (let step = 0; step < steps; step += 1) {
708
1014
  scene.physics.world.singleStep();
@@ -711,6 +1017,15 @@ async function createHeadlessGame(scene, options = {}) {
711
1017
  throwRuntimeError();
712
1018
  assertHealth();
713
1019
  },
1020
+ checkpoint(id) {
1021
+ const normalizedId = id.trim();
1022
+ if (!normalizedId || normalizedId.length > 120) {
1023
+ throw new Error(
1024
+ "Gameplay checkpoint id must contain 1 to 120 non-whitespace characters."
1025
+ );
1026
+ }
1027
+ appendUnique(checkpoints, normalizedId);
1028
+ },
714
1029
  assertTextHealth() {
715
1030
  assertSceneTextHealth(scene);
716
1031
  },
@@ -723,6 +1038,7 @@ async function createHeadlessGame(scene, options = {}) {
723
1038
  readyGame.destroy(true);
724
1039
  readyGame.headlessStep(readyGame.loop.lastTime, 0);
725
1040
  throwRuntimeError();
1041
+ evidence.destroyed = true;
726
1042
  } finally {
727
1043
  removeRuntimeListeners();
728
1044
  restoreHostGuards();
@@ -744,9 +1060,11 @@ var SmokeScene = class extends Phaser4.Scene {
744
1060
  this.randomValue = Phaser4.Math.RND.frac();
745
1061
  this.movingObject = this.add.zone(40, 50, 16, 16);
746
1062
  this.textObject = this.add.text(8, 8, "READY", { fontSize: "16px" });
747
- this.physics.add.existing(this.movingObject);
748
- const body = this.movingObject.body;
749
- body.setVelocityX(60);
1063
+ if (this.physics) {
1064
+ this.physics.add.existing(this.movingObject);
1065
+ const body = this.movingObject.body;
1066
+ body.setVelocityX(60);
1067
+ }
750
1068
  }
751
1069
  update() {
752
1070
  this.updates += 1;
@@ -767,6 +1085,38 @@ var LayeredInputScene = class extends Phaser4.Scene {
767
1085
  });
768
1086
  }
769
1087
  };
1088
+ var KeyboardInputScene = class extends Phaser4.Scene {
1089
+ playerX = 0;
1090
+ rightKey;
1091
+ constructor() {
1092
+ super("KeyboardInputScene");
1093
+ }
1094
+ create() {
1095
+ this.rightKey = this.input.keyboard.addKey(
1096
+ Phaser4.Input.Keyboard.KeyCodes.RIGHT
1097
+ );
1098
+ }
1099
+ update() {
1100
+ if (this.rightKey.isDown) this.playerX += 1;
1101
+ }
1102
+ };
1103
+ var TouchInputScene = class extends Phaser4.Scene {
1104
+ moves = 0;
1105
+ taps = 0;
1106
+ wasTouch = false;
1107
+ constructor() {
1108
+ super("TouchInputScene");
1109
+ }
1110
+ create() {
1111
+ this.add.zone(80, 60, 40, 40).setInteractive().on("pointerup", (pointer) => {
1112
+ this.taps += 1;
1113
+ this.wasTouch = pointer.wasTouch;
1114
+ });
1115
+ this.input.on("pointermove", (pointer) => {
1116
+ if (pointer.wasTouch) this.moves += 1;
1117
+ });
1118
+ }
1119
+ };
770
1120
  describe("Phaser HEADLESS host", () => {
771
1121
  let host;
772
1122
  afterEach(() => {
@@ -774,7 +1124,12 @@ describe("Phaser HEADLESS host", () => {
774
1124
  host = void 0;
775
1125
  });
776
1126
  it("boots a scene without a renderer and advances Arcade Physics", async () => {
777
- host = await createHeadlessGame(new SmokeScene());
1127
+ host = await createHeadlessGame(new SmokeScene(), {
1128
+ physics: {
1129
+ default: "arcade",
1130
+ arcade: { gravity: { x: 0, y: 0 }, fixedStep: true }
1131
+ }
1132
+ });
778
1133
  expect(host.game.renderer).toBeNull();
779
1134
  expect(host.scene.sys.isActive()).toBe(true);
780
1135
  const initialX = host.scene.movingObject.x;
@@ -786,14 +1141,35 @@ describe("Phaser HEADLESS host", () => {
786
1141
  host.stepFrames(3);
787
1142
  expect(host.scene.updates).toBe(initialUpdates + 3);
788
1143
  });
1144
+ it("rejects invalid advancement counts instead of entering an unbounded loop", async () => {
1145
+ const boundedHost = await createHeadlessGame(new LayeredInputScene());
1146
+ expect(() => boundedHost.stepFrames(Number.POSITIVE_INFINITY)).toThrow(
1147
+ "stepFrames count must be a non-negative safe integer"
1148
+ );
1149
+ expect(() => boundedHost.stepUntil(() => false, 1.5)).toThrow(
1150
+ "stepUntil maxFrames must be a non-negative safe integer"
1151
+ );
1152
+ expect(() => boundedHost.stepPhysics(-1)).toThrow(
1153
+ "stepPhysics steps must be a non-negative safe integer"
1154
+ );
1155
+ boundedHost.destroy();
1156
+ });
789
1157
  it("drives scaled DOM mouse input through hit testing and top-only dispatch", async () => {
790
1158
  const inputHost = await createHeadlessGame(new LayeredInputScene(), {
791
1159
  width: 160,
792
1160
  height: 120
793
1161
  });
794
1162
  inputHost.stepFrames();
795
- inputHost.input.setCanvasBounds({ left: 20, top: 30, width: 80, height: 60 });
796
- expect(inputHost.input.gameToClient(80, 60)).toEqual({ clientX: 60, clientY: 60 });
1163
+ inputHost.input.setCanvasBounds({
1164
+ left: 20,
1165
+ top: 30,
1166
+ width: 80,
1167
+ height: 60
1168
+ });
1169
+ expect(inputHost.input.gameToClient(80, 60)).toEqual({
1170
+ clientX: 60,
1171
+ clientY: 60
1172
+ });
797
1173
  await inputHost.input.mouse.click(80, 60);
798
1174
  expect({
799
1175
  pointerX: inputHost.scene.input.activePointer.x,
@@ -813,6 +1189,68 @@ describe("Phaser HEADLESS host", () => {
813
1189
  expect(inputHost.evidence.clicks).toBe(2);
814
1190
  inputHost.destroy();
815
1191
  });
1192
+ it("drives held DOM keyboard input through Phaser's configured target", async () => {
1193
+ const keyboardHost = await createHeadlessGame(new KeyboardInputScene());
1194
+ await keyboardHost.input.keyboard.down(
1195
+ Phaser4.Input.Keyboard.KeyCodes.RIGHT
1196
+ );
1197
+ keyboardHost.stepFrames(3);
1198
+ expect(keyboardHost.scene.playerX).toBe(3);
1199
+ await keyboardHost.input.keyboard.up(Phaser4.Input.Keyboard.KeyCodes.RIGHT);
1200
+ keyboardHost.stepFrames(2);
1201
+ expect(keyboardHost.scene.playerX).toBe(3);
1202
+ expect(keyboardHost.evidence.keyboardEvents).toBe(2);
1203
+ expect(
1204
+ () => keyboardHost.assertGameplayEvidence({ requireInput: true })
1205
+ ).not.toThrow();
1206
+ keyboardHost.destroy();
1207
+ });
1208
+ it("drives DOM touch input through pointer conversion and hit testing", async () => {
1209
+ const touchHost = await createHeadlessGame(new TouchInputScene(), {
1210
+ width: 160,
1211
+ height: 120
1212
+ });
1213
+ touchHost.stepFrames();
1214
+ touchHost.input.setCanvasBounds({
1215
+ left: 20,
1216
+ top: 30,
1217
+ width: 80,
1218
+ height: 60
1219
+ });
1220
+ await touchHost.input.touch.start(40, 40);
1221
+ await touchHost.input.touch.move(80, 60);
1222
+ await touchHost.input.touch.end(80, 60);
1223
+ expect({
1224
+ moves: touchHost.scene.moves,
1225
+ pointerX: touchHost.scene.input.activePointer.x,
1226
+ pointerY: touchHost.scene.input.activePointer.y,
1227
+ taps: touchHost.scene.taps,
1228
+ wasTouch: touchHost.scene.wasTouch
1229
+ }).toEqual({
1230
+ moves: 1,
1231
+ pointerX: 80,
1232
+ pointerY: 60,
1233
+ taps: 1,
1234
+ wasTouch: true
1235
+ });
1236
+ expect(touchHost.evidence.touchEvents).toBe(3);
1237
+ expect(
1238
+ () => touchHost.assertGameplayEvidence({ requireInput: true })
1239
+ ).not.toThrow();
1240
+ touchHost.destroy();
1241
+ });
1242
+ it("boots when a production input family is disabled", async () => {
1243
+ const mouseOnlyHost = await createHeadlessGame(new SmokeScene(), {
1244
+ input: { keyboard: false, touch: false }
1245
+ });
1246
+ await expect(
1247
+ mouseOnlyHost.input.keyboard.press(Phaser4.Input.Keyboard.KeyCodes.SPACE)
1248
+ ).rejects.toThrow("keyboard input target cannot dispatch DOM events");
1249
+ await expect(mouseOnlyHost.input.touch.tap(20, 20)).rejects.toThrow(
1250
+ "touch input target cannot dispatch DOM events"
1251
+ );
1252
+ mouseOnlyHost.destroy();
1253
+ });
816
1254
  it("registers transition targets before boot and settles queued transitions", async () => {
817
1255
  class TargetScene extends Phaser4.Scene {
818
1256
  receivedValue = 0;
@@ -841,8 +1279,12 @@ describe("Phaser HEADLESS host", () => {
841
1279
  transitionHost.stepFrames();
842
1280
  expect(transitionHost.game.scene.isActive("TargetScene")).toBe(false);
843
1281
  await transitionHost.input.mouse.click(40, 40);
844
- transitionHost.stepUntil(() => transitionHost.game.scene.isActive("TargetScene"));
845
- const target = transitionHost.game.scene.getScene("TargetScene");
1282
+ transitionHost.stepUntil(
1283
+ () => transitionHost.game.scene.isActive("TargetScene")
1284
+ );
1285
+ const target = transitionHost.game.scene.getScene(
1286
+ "TargetScene"
1287
+ );
846
1288
  expect(target.receivedValue).toBe(7);
847
1289
  expect(transitionHost.game.scene.isActive("SourceScene")).toBe(false);
848
1290
  transitionHost.assertGameplayEvidence({
@@ -866,11 +1308,39 @@ describe("Phaser HEADLESS host", () => {
866
1308
  optionalHost.stepFrames();
867
1309
  expect(optionalHost.scene.sys.isActive()).toBe(true);
868
1310
  expect(optionalHost.evidence.transitions).toEqual([
869
- { from: "OptionalCommandScene", to: "OptionalCommandScene", method: "sleep" },
870
- { from: "OptionalCommandScene", to: "OptionalCommandScene", method: "wake" }
1311
+ {
1312
+ from: "OptionalCommandScene",
1313
+ to: "OptionalCommandScene",
1314
+ method: "sleep"
1315
+ },
1316
+ {
1317
+ from: "OptionalCommandScene",
1318
+ to: "OptionalCommandScene",
1319
+ method: "wake"
1320
+ }
871
1321
  ]);
872
1322
  optionalHost.destroy();
873
1323
  });
1324
+ it("records Scene visits, restart, checkpoints, and destruction", async () => {
1325
+ const evidenceHost = await createHeadlessGame(new SmokeScene());
1326
+ evidenceHost.checkpoint("progress");
1327
+ evidenceHost.scene.scene.restart();
1328
+ evidenceHost.stepFrames();
1329
+ evidenceHost.destroy();
1330
+ expect(evidenceHost.evidence.registeredScenes).toContain("SmokeScene");
1331
+ expect(evidenceHost.evidence.visitedScenes).toContain("SmokeScene");
1332
+ expect(evidenceHost.evidence.restartedScenes).toContain("SmokeScene");
1333
+ expect(evidenceHost.evidence.checkpoints).toEqual(["progress"]);
1334
+ expect(evidenceHost.evidence.destroyed).toBe(true);
1335
+ expect(
1336
+ () => evidenceHost.assertGameplayEvidence({
1337
+ requireScene: "SmokeScene",
1338
+ requireRestart: "SmokeScene",
1339
+ requireCheckpoint: "progress",
1340
+ requireDestroy: true
1341
+ })
1342
+ ).not.toThrow();
1343
+ });
874
1344
  it("surfaces exceptions thrown by DOM-driven input callbacks", async () => {
875
1345
  class BrokenInputScene extends Phaser4.Scene {
876
1346
  create() {
@@ -918,7 +1388,9 @@ describe("Phaser HEADLESS host", () => {
918
1388
  (zone.input?.hitArea).width = 0;
919
1389
  }
920
1390
  }
921
- await expect(createHeadlessGame(new InvalidInteractiveScene())).rejects.toThrow(
1391
+ await expect(
1392
+ createHeadlessGame(new InvalidInteractiveScene())
1393
+ ).rejects.toThrow(
922
1394
  /interactive health check failed.*invalid default hit area/s
923
1395
  );
924
1396
  });
@@ -942,9 +1414,9 @@ describe("Phaser HEADLESS host", () => {
942
1414
  this.add.zone(Number.NaN, 20, 10, 10);
943
1415
  }
944
1416
  }
945
- await expect(createHeadlessGame(new InvalidTransformScene())).rejects.toThrow(
946
- /runtime health check failed.*non-finite x: NaN/s
947
- );
1417
+ await expect(
1418
+ createHeadlessGame(new InvalidTransformScene())
1419
+ ).rejects.toThrow(/runtime health check failed.*non-finite x: NaN/s);
948
1420
  });
949
1421
  it("rejects zero Camera zoom but allows negative zoom", async () => {
950
1422
  class InvalidCameraScene extends Phaser4.Scene {
@@ -971,9 +1443,11 @@ describe("Phaser HEADLESS host", () => {
971
1443
  object.body.velocity.x = Number.NaN;
972
1444
  }
973
1445
  }
974
- await expect(createHeadlessGame(new InvalidBodyScene())).rejects.toThrow(
975
- /runtime health check failed.*velocity.x: NaN/s
976
- );
1446
+ await expect(
1447
+ createHeadlessGame(new InvalidBodyScene(), {
1448
+ physics: { default: "arcade" }
1449
+ })
1450
+ ).rejects.toThrow(/runtime health check failed.*velocity.x: NaN/s);
977
1451
  class DisabledBodyScene extends Phaser4.Scene {
978
1452
  create() {
979
1453
  const object = this.add.zone(20, 20, 10, 10);
@@ -983,7 +1457,9 @@ describe("Phaser HEADLESS host", () => {
983
1457
  body.velocity.x = Number.NaN;
984
1458
  }
985
1459
  }
986
- const disabledHost = await createHeadlessGame(new DisabledBodyScene());
1460
+ const disabledHost = await createHeadlessGame(new DisabledBodyScene(), {
1461
+ physics: { default: "arcade" }
1462
+ });
987
1463
  expect(() => disabledHost.destroy()).not.toThrow();
988
1464
  });
989
1465
  it("captures rejected async lifecycle hooks", async () => {
@@ -1046,7 +1522,8 @@ describe("Phaser HEADLESS host", () => {
1046
1522
  this.add.text(8, 8, "NO PHYSICS");
1047
1523
  }
1048
1524
  }
1049
- const noPhysicsHost = await createHeadlessGame(new NoPhysicsScene(), { physics: void 0 });
1525
+ const noPhysicsHost = await createHeadlessGame(new NoPhysicsScene());
1526
+ expect(noPhysicsHost.scene.physics).toBeUndefined();
1050
1527
  expect(() => noPhysicsHost.stepFrames()).not.toThrow();
1051
1528
  expect(() => noPhysicsHost.stepPhysics()).toThrow(
1052
1529
  "stepPhysics() requires an Arcade Physics world"