miaoda-game-devkit 0.1.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.
- package/README.md +70 -0
- package/bin/miaoda-game-lint.js +3 -0
- package/biome-config.json +24 -0
- package/dist/cli/lint.js +93 -0
- package/dist/index.d.mts +111 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +827 -0
- package/dist/index.mjs +785 -0
- package/dist/lint/setup.mjs +125 -0
- package/dist/rules/check-image-import-plugin.js +92 -0
- package/dist/rules/check-style-import-plugin.js +92 -0
- package/dist/vitest-config.d.mts +23 -0
- package/dist/vitest-config.d.ts +23 -0
- package/dist/vitest-config.js +83 -0
- package/dist/vitest-config.mjs +60 -0
- package/oxlint-config.json +14 -0
- package/package.json +89 -0
- package/tsconfig-base.json +22 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
// src/phaser-headless-host.ts
|
|
2
|
+
import * as Phaser3 from "phaser";
|
|
3
|
+
|
|
4
|
+
// src/phaser-text-assertions.ts
|
|
5
|
+
import * as Phaser from "phaser";
|
|
6
|
+
function describeText(label) {
|
|
7
|
+
const name = label.name ? ` name=${JSON.stringify(label.name)}` : "";
|
|
8
|
+
const value = label.text.length > 80 ? `${label.text.slice(0, 77)}...` : label.text;
|
|
9
|
+
return `Text${name} value=${JSON.stringify(value)}`;
|
|
10
|
+
}
|
|
11
|
+
function isEffectivelyRenderable(label) {
|
|
12
|
+
let current = label;
|
|
13
|
+
while (current) {
|
|
14
|
+
const state = current;
|
|
15
|
+
if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
current = state.parentContainer ?? null;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
function requireFinite(problems, label, property, value) {
|
|
23
|
+
if (!Number.isFinite(value)) {
|
|
24
|
+
problems.push(`${describeText(label)} has non-finite ${property}: ${String(value)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function collectSceneTexts(scene) {
|
|
28
|
+
const labels = [];
|
|
29
|
+
const visited = /* @__PURE__ */ new Set();
|
|
30
|
+
const visit = (child) => {
|
|
31
|
+
if (visited.has(child)) return;
|
|
32
|
+
visited.add(child);
|
|
33
|
+
if (child instanceof Phaser.GameObjects.Text) {
|
|
34
|
+
labels.push(child);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (child instanceof Phaser.GameObjects.Container) {
|
|
38
|
+
child.list.forEach(visit);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (child instanceof Phaser.GameObjects.Layer) {
|
|
42
|
+
child.getChildren().forEach(visit);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
scene.children.getChildren().forEach(visit);
|
|
46
|
+
return labels;
|
|
47
|
+
}
|
|
48
|
+
function collectSceneBitmapTexts(scene) {
|
|
49
|
+
const labels = [];
|
|
50
|
+
const visited = /* @__PURE__ */ new Set();
|
|
51
|
+
const visit = (child) => {
|
|
52
|
+
if (visited.has(child)) return;
|
|
53
|
+
visited.add(child);
|
|
54
|
+
if (child instanceof Phaser.GameObjects.BitmapText) {
|
|
55
|
+
labels.push(child);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (child instanceof Phaser.GameObjects.Container) {
|
|
59
|
+
child.list.forEach(visit);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (child instanceof Phaser.GameObjects.Layer) {
|
|
63
|
+
child.getChildren().forEach(visit);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
scene.children.getChildren().forEach(visit);
|
|
67
|
+
return labels;
|
|
68
|
+
}
|
|
69
|
+
function collectMissingBitmapGlyphs(text, chars) {
|
|
70
|
+
const missing = [];
|
|
71
|
+
const seen = /* @__PURE__ */ new Set();
|
|
72
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
73
|
+
const character = text[index];
|
|
74
|
+
if (/\s/u.test(character)) continue;
|
|
75
|
+
const code = text.charCodeAt(index);
|
|
76
|
+
if (seen.has(code) || chars[code] !== void 0) continue;
|
|
77
|
+
seen.add(code);
|
|
78
|
+
missing.push(`${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`);
|
|
79
|
+
}
|
|
80
|
+
return missing;
|
|
81
|
+
}
|
|
82
|
+
function collectTextHealthProblems(scene) {
|
|
83
|
+
const problems = [];
|
|
84
|
+
const labels = collectSceneTexts(scene);
|
|
85
|
+
for (const label of labels) {
|
|
86
|
+
if (label.text.length === 0) continue;
|
|
87
|
+
requireFinite(problems, label, "x", label.x);
|
|
88
|
+
requireFinite(problems, label, "y", label.y);
|
|
89
|
+
requireFinite(problems, label, "width", label.width);
|
|
90
|
+
requireFinite(problems, label, "height", label.height);
|
|
91
|
+
requireFinite(problems, label, "displayWidth", label.displayWidth);
|
|
92
|
+
requireFinite(problems, label, "displayHeight", label.displayHeight);
|
|
93
|
+
requireFinite(problems, label, "scaleX", label.scaleX);
|
|
94
|
+
requireFinite(problems, label, "scaleY", label.scaleY);
|
|
95
|
+
requireFinite(problems, label, "originX", label.originX);
|
|
96
|
+
requireFinite(problems, label, "originY", label.originY);
|
|
97
|
+
requireFinite(problems, label, "rotation", label.rotation);
|
|
98
|
+
requireFinite(problems, label, "fixedWidth", label.style.fixedWidth);
|
|
99
|
+
requireFinite(problems, label, "fixedHeight", label.style.fixedHeight);
|
|
100
|
+
requireFinite(problems, label, "resolution", label.style.resolution);
|
|
101
|
+
const requiresPositiveSize = /\S/u.test(label.text) && isEffectivelyRenderable(label);
|
|
102
|
+
if (requiresPositiveSize && Number.isFinite(label.width) && label.width <= 0) {
|
|
103
|
+
problems.push(`${describeText(label)} has non-positive width: ${label.width}`);
|
|
104
|
+
}
|
|
105
|
+
if (requiresPositiveSize && Number.isFinite(label.height) && label.height <= 0) {
|
|
106
|
+
problems.push(`${describeText(label)} has non-positive height: ${label.height}`);
|
|
107
|
+
}
|
|
108
|
+
if (Number.isFinite(label.style.fixedWidth) && label.style.fixedWidth < 0) {
|
|
109
|
+
problems.push(`${describeText(label)} has negative fixedWidth: ${label.style.fixedWidth}`);
|
|
110
|
+
}
|
|
111
|
+
if (Number.isFinite(label.style.fixedHeight) && label.style.fixedHeight < 0) {
|
|
112
|
+
problems.push(`${describeText(label)} has negative fixedHeight: ${label.style.fixedHeight}`);
|
|
113
|
+
}
|
|
114
|
+
if (Number.isFinite(label.style.resolution) && label.style.resolution <= 0) {
|
|
115
|
+
problems.push(`${describeText(label)} has non-positive resolution: ${label.style.resolution}`);
|
|
116
|
+
}
|
|
117
|
+
const bounds = label.getBounds();
|
|
118
|
+
for (const [property, value] of Object.entries({
|
|
119
|
+
boundsX: bounds.x,
|
|
120
|
+
boundsY: bounds.y,
|
|
121
|
+
boundsWidth: bounds.width,
|
|
122
|
+
boundsHeight: bounds.height
|
|
123
|
+
})) {
|
|
124
|
+
requireFinite(problems, label, property, value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return problems;
|
|
128
|
+
}
|
|
129
|
+
function collectBitmapTextHealthProblems(scene) {
|
|
130
|
+
const problems = [];
|
|
131
|
+
for (const label of collectSceneBitmapTexts(scene)) {
|
|
132
|
+
const text = Array.isArray(label.text) ? label.text.join("\n") : label.text;
|
|
133
|
+
if (!/\S/u.test(text)) continue;
|
|
134
|
+
const missing = collectMissingBitmapGlyphs(text, label.fontData.chars);
|
|
135
|
+
if (missing.length > 0) {
|
|
136
|
+
problems.push(
|
|
137
|
+
`BitmapText${label.name ? ` name=${JSON.stringify(label.name)}` : ""} value=${JSON.stringify(text)} is missing glyphs: ${missing.join(", ")}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return problems;
|
|
142
|
+
}
|
|
143
|
+
function assertSceneTextHealth(scene) {
|
|
144
|
+
const problems = [
|
|
145
|
+
...collectTextHealthProblems(scene),
|
|
146
|
+
...collectBitmapTextHealthProblems(scene)
|
|
147
|
+
];
|
|
148
|
+
if (problems.length === 0) return;
|
|
149
|
+
throw new Error([
|
|
150
|
+
`Phaser text health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
151
|
+
...problems.map((problem) => `- ${problem}`),
|
|
152
|
+
"Fix optional TextStyle values by omitting them or using Phaser defaults (for example fixedWidth: value ?? 0)."
|
|
153
|
+
].join("\n"));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/phaser-runtime-assertions.ts
|
|
157
|
+
import * as Phaser2 from "phaser";
|
|
158
|
+
function collectObjects(scene) {
|
|
159
|
+
const objects = [];
|
|
160
|
+
const visited = /* @__PURE__ */ new Set();
|
|
161
|
+
const visit = (object) => {
|
|
162
|
+
if (visited.has(object)) return;
|
|
163
|
+
visited.add(object);
|
|
164
|
+
objects.push(object);
|
|
165
|
+
if (object instanceof Phaser2.GameObjects.Container) object.list.forEach(visit);
|
|
166
|
+
if (object instanceof Phaser2.GameObjects.Layer) object.getChildren().forEach(visit);
|
|
167
|
+
};
|
|
168
|
+
scene.children.getChildren().forEach(visit);
|
|
169
|
+
return objects;
|
|
170
|
+
}
|
|
171
|
+
function describeObject(object) {
|
|
172
|
+
return `${object.type}${object.name ? ` name=${JSON.stringify(object.name)}` : ""}`;
|
|
173
|
+
}
|
|
174
|
+
function isEffectivelyVisible(object) {
|
|
175
|
+
let current = object;
|
|
176
|
+
while (current) {
|
|
177
|
+
const state = current;
|
|
178
|
+
if (state.visible === false || state.alpha === 0 || state.scaleX === 0 || state.scaleY === 0) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
current = state.parentContainer ?? null;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
function requireFiniteProperty(problems, subject, target, property) {
|
|
186
|
+
if (!(property in target)) return;
|
|
187
|
+
const value = Reflect.get(target, property);
|
|
188
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
189
|
+
problems.push(`${subject} has non-finite ${property}: ${String(value)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function requireFiniteVector(problems, subject, target, property) {
|
|
193
|
+
if (!(property in target)) return;
|
|
194
|
+
const vector = Reflect.get(target, property);
|
|
195
|
+
if (!vector || typeof vector !== "object") return;
|
|
196
|
+
for (const axis of ["x", "y"]) {
|
|
197
|
+
if (!(axis in vector)) continue;
|
|
198
|
+
const value = Reflect.get(vector, axis);
|
|
199
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
200
|
+
problems.push(`${subject} has non-finite ${property}.${axis}: ${String(value)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function checksObjectState(object) {
|
|
205
|
+
return object.active !== false || isEffectivelyVisible(object) || Boolean(object.input?.enabled);
|
|
206
|
+
}
|
|
207
|
+
function collectGameObjectHealthProblems(scene) {
|
|
208
|
+
const problems = [];
|
|
209
|
+
const transformProperties = ["x", "y", "scaleX", "scaleY", "rotation"];
|
|
210
|
+
const optionalComponents = [
|
|
211
|
+
["setAlpha", ["alpha", "_alphaTL", "_alphaTR", "_alphaBL", "_alphaBR"]],
|
|
212
|
+
["setDepth", ["depth"]],
|
|
213
|
+
["setOrigin", ["originX", "originY", "displayOriginX", "displayOriginY"]],
|
|
214
|
+
["setScrollFactor", ["scrollFactorX", "scrollFactorY"]]
|
|
215
|
+
];
|
|
216
|
+
for (const object of collectObjects(scene)) {
|
|
217
|
+
if (!checksObjectState(object)) continue;
|
|
218
|
+
const subject = describeObject(object);
|
|
219
|
+
const target = object;
|
|
220
|
+
if (typeof target.setPosition === "function") {
|
|
221
|
+
for (const property of transformProperties) {
|
|
222
|
+
requireFiniteProperty(problems, subject, object, property);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const [method, properties] of optionalComponents) {
|
|
226
|
+
if (typeof target[method] !== "function") continue;
|
|
227
|
+
for (const property of properties) {
|
|
228
|
+
requireFiniteProperty(problems, subject, object, property);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return problems;
|
|
233
|
+
}
|
|
234
|
+
function collectCameraHealthProblems(scene) {
|
|
235
|
+
const problems = [];
|
|
236
|
+
const properties = [
|
|
237
|
+
"x",
|
|
238
|
+
"y",
|
|
239
|
+
"width",
|
|
240
|
+
"height",
|
|
241
|
+
"scrollX",
|
|
242
|
+
"scrollY",
|
|
243
|
+
"rotation",
|
|
244
|
+
"zoomX",
|
|
245
|
+
"zoomY"
|
|
246
|
+
];
|
|
247
|
+
for (const camera of scene.cameras?.cameras ?? []) {
|
|
248
|
+
if (camera.visible === false) continue;
|
|
249
|
+
const subject = `Camera ${JSON.stringify(camera.name || String(camera.id))}`;
|
|
250
|
+
for (const property of properties) {
|
|
251
|
+
requireFiniteProperty(problems, subject, camera, property);
|
|
252
|
+
}
|
|
253
|
+
if (camera.width !== 0 && camera.height !== 0 && (camera.zoomX === 0 || camera.zoomY === 0)) {
|
|
254
|
+
problems.push(`${subject} has zero zoom while its viewport is active`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return problems;
|
|
258
|
+
}
|
|
259
|
+
function getArcadeBodyCollection(value) {
|
|
260
|
+
if (value instanceof Set) return [...value];
|
|
261
|
+
if (!value || typeof value !== "object") return void 0;
|
|
262
|
+
const entries = Reflect.get(value, "entries");
|
|
263
|
+
return Array.isArray(entries) ? entries : void 0;
|
|
264
|
+
}
|
|
265
|
+
function getArcadeWorldBodies(world) {
|
|
266
|
+
if (!world || typeof world !== "object") return void 0;
|
|
267
|
+
const bodies = getArcadeBodyCollection(Reflect.get(world, "bodies"));
|
|
268
|
+
const staticBodies = getArcadeBodyCollection(Reflect.get(world, "staticBodies"));
|
|
269
|
+
return bodies && staticBodies ? [...bodies, ...staticBodies] : void 0;
|
|
270
|
+
}
|
|
271
|
+
function collectArcadeBodyHealthProblems(scene) {
|
|
272
|
+
const problems = [];
|
|
273
|
+
const bodies = getArcadeWorldBodies(scene.physics?.world);
|
|
274
|
+
if (!bodies) return problems;
|
|
275
|
+
for (const body of bodies) {
|
|
276
|
+
if (body.enable === false) continue;
|
|
277
|
+
const gameObject = body.gameObject;
|
|
278
|
+
const subject = gameObject ? `Arcade Body for ${describeObject(gameObject)}` : "Arcade Body";
|
|
279
|
+
for (const property of ["position", "velocity", "acceleration", "gravity", "offset", "center"]) {
|
|
280
|
+
requireFiniteVector(problems, subject, body, property);
|
|
281
|
+
}
|
|
282
|
+
for (const property of ["width", "height", "halfWidth", "halfHeight", "rotation"]) {
|
|
283
|
+
requireFiniteProperty(problems, subject, body, property);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return problems;
|
|
287
|
+
}
|
|
288
|
+
function usesCustomHitArea(object) {
|
|
289
|
+
return Boolean(object.input?.customHitArea);
|
|
290
|
+
}
|
|
291
|
+
function collectInteractiveHealthProblems(scene) {
|
|
292
|
+
const problems = [];
|
|
293
|
+
for (const object of collectObjects(scene)) {
|
|
294
|
+
if (object.active === false || !isEffectivelyVisible(object) || !object.input?.enabled) continue;
|
|
295
|
+
if (usesCustomHitArea(object)) continue;
|
|
296
|
+
const hitArea = object.input.hitArea;
|
|
297
|
+
if (!hitArea) {
|
|
298
|
+
problems.push(`${describeObject(object)} has no default hit area`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const values = [hitArea.x, hitArea.y, hitArea.width, hitArea.height];
|
|
302
|
+
if (!values.every(Number.isFinite) || hitArea.width <= 0 || hitArea.height <= 0) {
|
|
303
|
+
problems.push(
|
|
304
|
+
`${describeObject(object)} has invalid default hit area (${hitArea.x}, ${hitArea.y}, ${hitArea.width}, ${hitArea.height})`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return problems;
|
|
309
|
+
}
|
|
310
|
+
function assertSceneRuntimeHealth(scene) {
|
|
311
|
+
const problems = [
|
|
312
|
+
...collectGameObjectHealthProblems(scene),
|
|
313
|
+
...collectCameraHealthProblems(scene),
|
|
314
|
+
...collectArcadeBodyHealthProblems(scene)
|
|
315
|
+
];
|
|
316
|
+
if (problems.length === 0) return;
|
|
317
|
+
throw new Error([
|
|
318
|
+
`Phaser runtime health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
319
|
+
...problems.map((problem) => `- ${problem}`),
|
|
320
|
+
"Keep values consumed by Phaser finite; zero size and zero scale remain allowed."
|
|
321
|
+
].join("\n"));
|
|
322
|
+
}
|
|
323
|
+
function assertSceneInteractiveHealth(scene) {
|
|
324
|
+
const problems = collectInteractiveHealthProblems(scene);
|
|
325
|
+
if (problems.length === 0) return;
|
|
326
|
+
throw new Error([
|
|
327
|
+
`Phaser interactive health check failed in scene ${JSON.stringify(scene.scene.key)}:`,
|
|
328
|
+
...problems.map((problem) => `- ${problem}`),
|
|
329
|
+
"Fix the default hit area or disable input before making the object interactive."
|
|
330
|
+
].join("\n"));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/phaser-headless-host.ts
|
|
334
|
+
async function createHeadlessGame(scene, options = {}) {
|
|
335
|
+
const { bootTimeoutMs = 2e3, additionalScenes = [], ...config } = options;
|
|
336
|
+
let game;
|
|
337
|
+
let settled = false;
|
|
338
|
+
let runtimeError;
|
|
339
|
+
let hasRuntimeError = false;
|
|
340
|
+
let restoreHostGuards = () => {
|
|
341
|
+
};
|
|
342
|
+
let removeRuntimeListeners = () => {
|
|
343
|
+
};
|
|
344
|
+
const transitions = [];
|
|
345
|
+
const evidence = {
|
|
346
|
+
frames: 0,
|
|
347
|
+
physicsSteps: 0,
|
|
348
|
+
mouseEvents: 0,
|
|
349
|
+
clicks: 0,
|
|
350
|
+
transitions
|
|
351
|
+
};
|
|
352
|
+
const created = new Promise((resolve, reject) => {
|
|
353
|
+
const guardedScenes = /* @__PURE__ */ new WeakSet();
|
|
354
|
+
const restoreGuards = [];
|
|
355
|
+
let removeReadinessCheck = () => {
|
|
356
|
+
};
|
|
357
|
+
const finish = (error) => {
|
|
358
|
+
if (settled) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
settled = true;
|
|
362
|
+
window.clearTimeout(timeout);
|
|
363
|
+
removeReadinessCheck();
|
|
364
|
+
if (error === void 0) {
|
|
365
|
+
game?.loop.stop();
|
|
366
|
+
resolve();
|
|
367
|
+
} else {
|
|
368
|
+
removeRuntimeListeners();
|
|
369
|
+
reject(error);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
const reportError = (error) => {
|
|
373
|
+
const normalizedError = error instanceof Error ? error : new Error(`Unhandled Phaser runtime error: ${String(error)}`);
|
|
374
|
+
if (!settled) {
|
|
375
|
+
finish(normalizedError);
|
|
376
|
+
} else if (!hasRuntimeError) {
|
|
377
|
+
hasRuntimeError = true;
|
|
378
|
+
runtimeError = normalizedError;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const onWindowError = (event) => {
|
|
382
|
+
if (event.defaultPrevented) return;
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
const error = event.error ?? new Error(event.message);
|
|
385
|
+
queueMicrotask(() => {
|
|
386
|
+
reportError(error);
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
const onUnhandledRejection = (event) => {
|
|
390
|
+
queueMicrotask(() => {
|
|
391
|
+
if (!event.defaultPrevented) reportError(event.reason);
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
const timeout = window.setTimeout(() => {
|
|
395
|
+
finish(new Error(`Phaser HEADLESS boot timed out after ${bootTimeoutMs}ms`));
|
|
396
|
+
}, bootTimeoutMs);
|
|
397
|
+
window.addEventListener("error", onWindowError, true);
|
|
398
|
+
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
|
399
|
+
removeRuntimeListeners = () => {
|
|
400
|
+
window.removeEventListener("error", onWindowError, true);
|
|
401
|
+
window.removeEventListener("unhandledrejection", onUnhandledRejection);
|
|
402
|
+
};
|
|
403
|
+
const guardScene = (currentScene) => {
|
|
404
|
+
if (guardedScenes.has(currentScene)) return;
|
|
405
|
+
guardedScenes.add(currentScene);
|
|
406
|
+
const scenePlugin = currentScene.scene;
|
|
407
|
+
for (const method of ["start", "launch", "switch", "sleep", "wake"]) {
|
|
408
|
+
const original = scenePlugin[method];
|
|
409
|
+
if (typeof original !== "function") continue;
|
|
410
|
+
scenePlugin[method] = (key, ...args) => {
|
|
411
|
+
const target = key === void 0 ? currentScene : typeof key === "string" ? currentScene.scene.get(key) : key;
|
|
412
|
+
if (target) guardScene(target);
|
|
413
|
+
const from = currentScene.sys.settings.key;
|
|
414
|
+
const to = key === void 0 ? from : typeof key === "string" ? key : key.sys.settings.key;
|
|
415
|
+
if (from && to) transitions.push({ from, to, method });
|
|
416
|
+
const callArgs = key === void 0 && args.length === 0 ? [] : [key, ...args];
|
|
417
|
+
return Reflect.apply(original, currentScene.scene, callArgs);
|
|
418
|
+
};
|
|
419
|
+
restoreGuards.push(() => {
|
|
420
|
+
scenePlugin[method] = original;
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
for (const hook of ["init", "preload", "create", "update"]) {
|
|
424
|
+
const original = Reflect.get(currentScene, hook);
|
|
425
|
+
if (typeof original !== "function") continue;
|
|
426
|
+
const ownDescriptor = Object.getOwnPropertyDescriptor(currentScene, hook);
|
|
427
|
+
Reflect.set(currentScene, hook, function guardedLifecycleHook(...args) {
|
|
428
|
+
try {
|
|
429
|
+
const result = Reflect.apply(original, this, args);
|
|
430
|
+
if (result && typeof result === "object" && "then" in result) {
|
|
431
|
+
const completion = Promise.resolve(result);
|
|
432
|
+
if (currentScene === scene && hook === "create" && !settled) {
|
|
433
|
+
completion.then(() => window.setTimeout(() => finish(), 0), reportError);
|
|
434
|
+
} else {
|
|
435
|
+
completion.catch(reportError);
|
|
436
|
+
}
|
|
437
|
+
} else if (currentScene === scene && hook === "create" && !settled) {
|
|
438
|
+
window.setTimeout(() => finish(), 0);
|
|
439
|
+
}
|
|
440
|
+
return result;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
reportError(error);
|
|
443
|
+
return void 0;
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
restoreGuards.push(() => {
|
|
447
|
+
if (ownDescriptor) {
|
|
448
|
+
Object.defineProperty(currentScene, hook, ownDescriptor);
|
|
449
|
+
} else {
|
|
450
|
+
Reflect.deleteProperty(currentScene, hook);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
restoreHostGuards = () => {
|
|
456
|
+
while (restoreGuards.length > 0) restoreGuards.pop()?.();
|
|
457
|
+
};
|
|
458
|
+
const fps = {
|
|
459
|
+
...config.fps,
|
|
460
|
+
target: config.fps?.target ?? 60,
|
|
461
|
+
forceSetTimeOut: true
|
|
462
|
+
};
|
|
463
|
+
const audio = {
|
|
464
|
+
...config.audio,
|
|
465
|
+
noAudio: true
|
|
466
|
+
};
|
|
467
|
+
try {
|
|
468
|
+
game = new Phaser3.Game({
|
|
469
|
+
width: 320,
|
|
470
|
+
height: 180,
|
|
471
|
+
banner: false,
|
|
472
|
+
autoFocus: false,
|
|
473
|
+
seed: ["phaser-headless-test"],
|
|
474
|
+
physics: {
|
|
475
|
+
default: "arcade",
|
|
476
|
+
arcade: {
|
|
477
|
+
gravity: { x: 0, y: 0 },
|
|
478
|
+
fixedStep: true
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
...config,
|
|
482
|
+
type: Phaser3.HEADLESS,
|
|
483
|
+
fps,
|
|
484
|
+
audio,
|
|
485
|
+
callbacks: {
|
|
486
|
+
preBoot(bootedGame) {
|
|
487
|
+
const manager = bootedGame.scene;
|
|
488
|
+
const originalStart = manager.start;
|
|
489
|
+
manager.start = ((key, data) => {
|
|
490
|
+
const currentScene = typeof key === "string" ? manager.getScene(key) : key;
|
|
491
|
+
if (currentScene) guardScene(currentScene);
|
|
492
|
+
return Reflect.apply(originalStart, manager, [key, data]);
|
|
493
|
+
});
|
|
494
|
+
restoreGuards.push(() => {
|
|
495
|
+
manager.start = originalStart;
|
|
496
|
+
});
|
|
497
|
+
},
|
|
498
|
+
postBoot(bootedGame) {
|
|
499
|
+
const checkReadiness = () => {
|
|
500
|
+
if (typeof Reflect.get(scene, "create") !== "function" && scene.sys.isActive()) {
|
|
501
|
+
finish();
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
bootedGame.events.on(Phaser3.Core.Events.POST_STEP, checkReadiness);
|
|
505
|
+
removeReadinessCheck = () => {
|
|
506
|
+
bootedGame.events.off(Phaser3.Core.Events.POST_STEP, checkReadiness);
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
scene: additionalScenes.length > 0 ? [scene, ...additionalScenes] : scene
|
|
511
|
+
});
|
|
512
|
+
} catch (error) {
|
|
513
|
+
reportError(error);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
try {
|
|
517
|
+
await created;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (game) {
|
|
520
|
+
game.destroy(true);
|
|
521
|
+
game.headlessStep(game.loop.lastTime, 0);
|
|
522
|
+
}
|
|
523
|
+
restoreHostGuards();
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
const readyGame = game;
|
|
527
|
+
const throwRuntimeError = () => {
|
|
528
|
+
if (hasRuntimeError) {
|
|
529
|
+
throw runtimeError instanceof Error ? runtimeError : new Error(`Unhandled Phaser runtime error: ${String(runtimeError)}`);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
const canvas = readyGame.canvas;
|
|
533
|
+
if (!canvas) {
|
|
534
|
+
removeRuntimeListeners();
|
|
535
|
+
restoreHostGuards();
|
|
536
|
+
readyGame.destroy(true);
|
|
537
|
+
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
538
|
+
throw new Error("Phaser HEADLESS did not create an input canvas");
|
|
539
|
+
}
|
|
540
|
+
const inputWindow = canvas.ownerDocument.defaultView;
|
|
541
|
+
if (!inputWindow) {
|
|
542
|
+
removeRuntimeListeners();
|
|
543
|
+
restoreHostGuards();
|
|
544
|
+
readyGame.destroy(true);
|
|
545
|
+
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
546
|
+
throw new Error("Phaser HEADLESS input canvas is not attached to a DOM Window");
|
|
547
|
+
}
|
|
548
|
+
let canvasBounds = {
|
|
549
|
+
left: 0,
|
|
550
|
+
top: 0,
|
|
551
|
+
width: canvas.width,
|
|
552
|
+
height: canvas.height
|
|
553
|
+
};
|
|
554
|
+
const setCanvasBounds = (bounds) => {
|
|
555
|
+
const values = [bounds.left, bounds.top, bounds.width, bounds.height];
|
|
556
|
+
if (!values.every(Number.isFinite) || bounds.width <= 0 || bounds.height <= 0) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`HEADLESS canvas bounds must be finite with positive dimensions; received ${JSON.stringify(bounds)}`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
canvasBounds = { ...bounds };
|
|
562
|
+
canvas.getBoundingClientRect = () => ({
|
|
563
|
+
x: canvasBounds.left,
|
|
564
|
+
y: canvasBounds.top,
|
|
565
|
+
left: canvasBounds.left,
|
|
566
|
+
top: canvasBounds.top,
|
|
567
|
+
right: canvasBounds.left + canvasBounds.width,
|
|
568
|
+
bottom: canvasBounds.top + canvasBounds.height,
|
|
569
|
+
width: canvasBounds.width,
|
|
570
|
+
height: canvasBounds.height,
|
|
571
|
+
toJSON: () => ({ ...canvasBounds })
|
|
572
|
+
});
|
|
573
|
+
readyGame.scale.updateBounds();
|
|
574
|
+
readyGame.scale.displayScale.set(
|
|
575
|
+
readyGame.scale.baseSize.width / canvasBounds.width,
|
|
576
|
+
readyGame.scale.baseSize.height / canvasBounds.height
|
|
577
|
+
);
|
|
578
|
+
};
|
|
579
|
+
setCanvasBounds(canvasBounds);
|
|
580
|
+
const gameToClient = (x, y) => {
|
|
581
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
582
|
+
throw new Error(`HEADLESS input coordinates must be finite; received (${x}, ${y})`);
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
clientX: canvasBounds.left + x / readyGame.scale.displayScale.x,
|
|
586
|
+
clientY: canvasBounds.top + y / readyGame.scale.displayScale.y
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
const assertHealth = () => {
|
|
590
|
+
for (const currentScene of readyGame.scene.getScenes(false)) {
|
|
591
|
+
if (!currentScene.sys.isActive() && !currentScene.sys.isPaused()) continue;
|
|
592
|
+
assertSceneTextHealth(currentScene);
|
|
593
|
+
assertSceneInteractiveHealth(currentScene);
|
|
594
|
+
assertSceneRuntimeHealth(currentScene);
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const settleRuntime = async () => {
|
|
598
|
+
await new Promise((resolve) => inputWindow.setTimeout(resolve, 0));
|
|
599
|
+
throwRuntimeError();
|
|
600
|
+
assertHealth();
|
|
601
|
+
};
|
|
602
|
+
const dispatchMouse = async (type, x, y, buttons) => {
|
|
603
|
+
const { clientX, clientY } = gameToClient(x, y);
|
|
604
|
+
canvas.dispatchEvent(new inputWindow.MouseEvent(type, {
|
|
605
|
+
bubbles: true,
|
|
606
|
+
cancelable: true,
|
|
607
|
+
button: 0,
|
|
608
|
+
buttons,
|
|
609
|
+
clientX,
|
|
610
|
+
clientY
|
|
611
|
+
}));
|
|
612
|
+
evidence.mouseEvents += 1;
|
|
613
|
+
await settleRuntime();
|
|
614
|
+
};
|
|
615
|
+
const input = {
|
|
616
|
+
canvas,
|
|
617
|
+
setCanvasBounds,
|
|
618
|
+
gameToClient,
|
|
619
|
+
settle: settleRuntime,
|
|
620
|
+
mouse: {
|
|
621
|
+
move: (x, y, buttons = 0) => dispatchMouse("mousemove", x, y, buttons),
|
|
622
|
+
down: (x, y) => dispatchMouse("mousedown", x, y, 1),
|
|
623
|
+
up: (x, y) => dispatchMouse("mouseup", x, y, 0),
|
|
624
|
+
async click(x, y) {
|
|
625
|
+
evidence.clicks += 1;
|
|
626
|
+
await dispatchMouse("mousemove", x, y, 0);
|
|
627
|
+
await dispatchMouse("mousedown", x, y, 1);
|
|
628
|
+
await dispatchMouse("mouseup", x, y, 0);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
const frameDurationMs = 1e3 / (config.fps?.target ?? 60);
|
|
633
|
+
const stepFrame = () => {
|
|
634
|
+
readyGame.loop.step(readyGame.loop.lastTime + frameDurationMs);
|
|
635
|
+
evidence.frames += 1;
|
|
636
|
+
};
|
|
637
|
+
const stepFrames = (count = 1) => {
|
|
638
|
+
throwRuntimeError();
|
|
639
|
+
for (let frame = 0; frame < count; frame += 1) stepFrame();
|
|
640
|
+
throwRuntimeError();
|
|
641
|
+
assertHealth();
|
|
642
|
+
};
|
|
643
|
+
try {
|
|
644
|
+
throwRuntimeError();
|
|
645
|
+
assertHealth();
|
|
646
|
+
} catch (error) {
|
|
647
|
+
removeRuntimeListeners();
|
|
648
|
+
restoreHostGuards();
|
|
649
|
+
readyGame.destroy(true);
|
|
650
|
+
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
let destroyed = false;
|
|
654
|
+
return {
|
|
655
|
+
game: readyGame,
|
|
656
|
+
scene,
|
|
657
|
+
settle: settleRuntime,
|
|
658
|
+
input,
|
|
659
|
+
evidence,
|
|
660
|
+
assertGameplayEvidence(requirements = {}) {
|
|
661
|
+
if (requirements.requireInput && evidence.mouseEvents === 0) {
|
|
662
|
+
throw new Error("Gameplay evidence is missing real HEADLESS input events.");
|
|
663
|
+
}
|
|
664
|
+
if (requirements.requireFrameAdvance && evidence.frames === 0) {
|
|
665
|
+
throw new Error("Gameplay evidence is missing complete Phaser frame advancement.");
|
|
666
|
+
}
|
|
667
|
+
if (requirements.requirePhysicsStep && evidence.physicsSteps === 0) {
|
|
668
|
+
throw new Error("Gameplay evidence is missing Arcade Physics advancement.");
|
|
669
|
+
}
|
|
670
|
+
const requiredTransitions = requirements.requireTransition === void 0 ? [] : typeof requirements.requireTransition === "string" ? [requirements.requireTransition] : requirements.requireTransition;
|
|
671
|
+
for (const target of requiredTransitions) {
|
|
672
|
+
if (!evidence.transitions.some((transition) => transition.to === target)) {
|
|
673
|
+
throw new Error(`Gameplay evidence is missing a transition to ${target}.`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
stepFrames,
|
|
678
|
+
async stepFramesAsync(count = 1) {
|
|
679
|
+
stepFrames(count);
|
|
680
|
+
await settleRuntime();
|
|
681
|
+
},
|
|
682
|
+
stepUntil(condition, maxFrames = 120) {
|
|
683
|
+
throwRuntimeError();
|
|
684
|
+
for (let frame = 0; frame <= maxFrames; frame += 1) {
|
|
685
|
+
if (condition()) {
|
|
686
|
+
assertHealth();
|
|
687
|
+
return frame;
|
|
688
|
+
}
|
|
689
|
+
if (frame < maxFrames) {
|
|
690
|
+
stepFrame();
|
|
691
|
+
throwRuntimeError();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
assertHealth();
|
|
695
|
+
throw new Error(`Condition was not met within ${maxFrames} complete Phaser frames`);
|
|
696
|
+
},
|
|
697
|
+
stepPhysics(steps = 1) {
|
|
698
|
+
throwRuntimeError();
|
|
699
|
+
const world = scene.physics?.world;
|
|
700
|
+
if (!world || !getArcadeWorldBodies(world) || typeof world.singleStep !== "function") {
|
|
701
|
+
throw new Error("stepPhysics() requires an Arcade Physics world; use stepFrames() for other configurations.");
|
|
702
|
+
}
|
|
703
|
+
for (let step = 0; step < steps; step += 1) {
|
|
704
|
+
scene.physics.world.singleStep();
|
|
705
|
+
evidence.physicsSteps += 1;
|
|
706
|
+
}
|
|
707
|
+
throwRuntimeError();
|
|
708
|
+
assertHealth();
|
|
709
|
+
},
|
|
710
|
+
assertTextHealth() {
|
|
711
|
+
assertSceneTextHealth(scene);
|
|
712
|
+
},
|
|
713
|
+
destroy() {
|
|
714
|
+
if (destroyed) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
destroyed = true;
|
|
718
|
+
try {
|
|
719
|
+
readyGame.destroy(true);
|
|
720
|
+
readyGame.headlessStep(readyGame.loop.lastTime, 0);
|
|
721
|
+
throwRuntimeError();
|
|
722
|
+
} finally {
|
|
723
|
+
removeRuntimeListeners();
|
|
724
|
+
restoreHostGuards();
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// src/gameplay-contracts.ts
|
|
731
|
+
function registerGameplayContract(context, options) {
|
|
732
|
+
const task = context;
|
|
733
|
+
const metadata = {
|
|
734
|
+
id: options.id,
|
|
735
|
+
requirements: {
|
|
736
|
+
requireInput: options.requireInput,
|
|
737
|
+
requireFrameAdvance: options.requireFrameAdvance,
|
|
738
|
+
requirePhysicsStep: options.requirePhysicsStep,
|
|
739
|
+
requireTransition: options.requireTransition
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
task.task.meta.gameplayContract = metadata;
|
|
743
|
+
let host;
|
|
744
|
+
let verified = false;
|
|
745
|
+
let testFailed = false;
|
|
746
|
+
context.onTestFailed?.(() => {
|
|
747
|
+
testFailed = true;
|
|
748
|
+
});
|
|
749
|
+
const verify = () => {
|
|
750
|
+
if (verified) return;
|
|
751
|
+
if (!host) {
|
|
752
|
+
throw new Error(`Gameplay contract "${options.id}" did not attach a HEADLESS host.`);
|
|
753
|
+
}
|
|
754
|
+
host.assertGameplayEvidence(metadata.requirements);
|
|
755
|
+
metadata.evidence = {
|
|
756
|
+
frames: host.evidence.frames,
|
|
757
|
+
physicsSteps: host.evidence.physicsSteps,
|
|
758
|
+
mouseEvents: host.evidence.mouseEvents,
|
|
759
|
+
clicks: host.evidence.clicks,
|
|
760
|
+
transitions: host.evidence.transitions.map((transition) => ({ ...transition }))
|
|
761
|
+
};
|
|
762
|
+
verified = true;
|
|
763
|
+
};
|
|
764
|
+
context.onTestFinished(() => {
|
|
765
|
+
if (testFailed) return;
|
|
766
|
+
verify();
|
|
767
|
+
});
|
|
768
|
+
return {
|
|
769
|
+
attachHost(currentHost) {
|
|
770
|
+
if (host) {
|
|
771
|
+
throw new Error(`Gameplay contract "${options.id}" attached more than one host.`);
|
|
772
|
+
}
|
|
773
|
+
host = currentHost;
|
|
774
|
+
},
|
|
775
|
+
verify
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
export {
|
|
779
|
+
assertSceneInteractiveHealth,
|
|
780
|
+
assertSceneRuntimeHealth,
|
|
781
|
+
assertSceneTextHealth,
|
|
782
|
+
collectMissingBitmapGlyphs,
|
|
783
|
+
createHeadlessGame,
|
|
784
|
+
registerGameplayContract
|
|
785
|
+
};
|