miaoda-game-devkit 0.10.3 → 0.10.5

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.
@@ -23,6 +23,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
23
23
  "useOwnedGameController"
24
24
  ]);
25
25
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
26
+ var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
27
+ "ball",
28
+ "bricks",
29
+ "enemies",
30
+ "entities",
31
+ "entity",
32
+ "elapsedMs",
33
+ "elapsedTimeMs",
34
+ "hoveredCell",
35
+ "paddle",
36
+ "particles",
37
+ "player",
38
+ "projectiles",
39
+ "snapshot",
40
+ "state"
41
+ ]);
26
42
  function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
27
43
  if (!(0, import_node_fs.existsSync)(directory)) return [];
28
44
  const files = [];
@@ -116,6 +132,88 @@ function containsAny(values, expected) {
116
132
  function isRecord(value) {
117
133
  return typeof value === "object" && value !== null;
118
134
  }
135
+ function visitAst(value, visitor) {
136
+ if (Array.isArray(value)) {
137
+ for (const item of value) visitAst(item, visitor);
138
+ return;
139
+ }
140
+ if (!isRecord(value)) return;
141
+ if (typeof value.type === "string") visitor(value);
142
+ for (const child of Object.values(value)) visitAst(child, visitor);
143
+ }
144
+ function identifierName(value) {
145
+ return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
146
+ }
147
+ function memberPath(value) {
148
+ if (!isRecord(value)) return [];
149
+ const identifier = identifierName(value);
150
+ if (identifier) return [identifier];
151
+ if (value.type !== "MemberExpression") return [];
152
+ return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
153
+ }
154
+ function hasRepeatedAnimationFrameCallback(value) {
155
+ const callbackCounts = /* @__PURE__ */ new Map();
156
+ visitAst(value, (node) => {
157
+ if (node.type !== "CallExpression") return;
158
+ if (identifierName(node.callee) !== "requestAnimationFrame") return;
159
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
160
+ const callback = identifierName(arguments_[0]);
161
+ if (!callback) return;
162
+ callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
163
+ });
164
+ return [...callbackCounts.values()].some((count) => count >= 2);
165
+ }
166
+ function reactEffectNames(file, source) {
167
+ const names = /* @__PURE__ */ new Set(["useEffect"]);
168
+ for (const declaration of staticImports(file, source)) {
169
+ if (declaration.moduleRequest.value !== "react") continue;
170
+ for (const entry of declaration.entries) {
171
+ if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
172
+ names.add(entry.localName.value);
173
+ }
174
+ }
175
+ }
176
+ return names;
177
+ }
178
+ function isReactEffectCall(node, effectNames) {
179
+ if (node.type !== "CallExpression") return false;
180
+ const directName = identifierName(node.callee);
181
+ if (directName && effectNames.has(directName)) return true;
182
+ const path = memberPath(node.callee);
183
+ return path.length === 2 && path[1] === "useEffect";
184
+ }
185
+ function canvasRafDependencyIssues(file, source, projectRoot2) {
186
+ const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
187
+ source
188
+ );
189
+ if (!ownsCanvas) return [];
190
+ const issues = [];
191
+ const effectNames = reactEffectNames(file, source);
192
+ visitAst(parseSource(file, source).program, (node) => {
193
+ if (!isReactEffectCall(node, effectNames)) return;
194
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
195
+ if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
196
+ const dependencies = arguments_[1];
197
+ if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
198
+ const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
199
+ const unsafe = elements.filter(
200
+ (element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
201
+ );
202
+ if (unsafe.length === 0) return;
203
+ const labels = unsafe.map((element) => {
204
+ if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
205
+ return "dynamic state";
206
+ }
207
+ return source.slice(element.start, element.end);
208
+ });
209
+ const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
210
+ const path = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
211
+ issues.push(
212
+ `CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
213
+ );
214
+ });
215
+ return issues;
216
+ }
119
217
  function containsObserveProperty(value) {
120
218
  if (Array.isArray(value)) return value.some(containsObserveProperty);
121
219
  if (!isRecord(value)) return false;
@@ -131,17 +229,20 @@ function declaresObserve(file, source) {
131
229
  function auditReactAuthoritativePlaythrough(projectRoot2) {
132
230
  const clockFiles = [];
133
231
  const controllerFiles = [];
232
+ const canvasRafIssues = [];
134
233
  const productionFiles = sourceFiles(projectRoot2);
135
234
  for (const file of productionFiles) {
235
+ const source = (0, import_node_fs.readFileSync)(file, "utf8");
136
236
  const imports = namedImports(
137
237
  file,
138
- (0, import_node_fs.readFileSync)(file, "utf8"),
238
+ source,
139
239
  REACT_RUNTIME_ENTRY
140
240
  );
141
241
  const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
142
242
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
143
243
  if (containsAny(imports, CONTROLLER_IMPORTS))
144
244
  controllerFiles.push(projectPath);
245
+ canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot2));
145
246
  }
146
247
  const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
147
248
  const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
@@ -152,6 +253,7 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
152
253
  (file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
153
254
  ).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
154
255
  const issues = [];
256
+ issues.push(...canvasRafIssues);
155
257
  if (staleExampleTestFiles.length > 0) {
156
258
  issues.push(
157
259
  `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
@@ -165,7 +267,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
165
267
  controllerFiles,
166
268
  productionEntryExists,
167
269
  productionUsesExample,
168
- staleExampleTestFiles
270
+ staleExampleTestFiles,
271
+ canvasRafIssues
169
272
  };
170
273
  }
171
274
  const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
@@ -193,7 +296,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
193
296
  controllerFiles,
194
297
  productionEntryExists,
195
298
  productionUsesExample,
196
- staleExampleTestFiles
299
+ staleExampleTestFiles,
300
+ canvasRafIssues
197
301
  };
198
302
  }
199
303
 
@@ -304,12 +408,13 @@ function checkVitestConfig(target) {
304
408
  }
305
409
  return { name, ok: true };
306
410
  }
307
- function checkReactProductTestAlignment() {
308
- const name = "react-product-test-alignment";
411
+ function checkReactSourceSafety() {
412
+ const name = "react-source-safety";
309
413
  const audit = auditReactAuthoritativePlaythrough(projectRoot);
310
- if (audit.staleExampleTestFiles.length === 0) return { name, ok: true };
311
- console.error(`[${name}] ${audit.issues[0]}`);
312
- return { name, ok: false };
414
+ const blockingIssues = (audit.staleExampleTestFiles.length > 0 ? [audit.issues.find((issue) => issue.includes("runnable product tests still do"))] : []).filter((issue) => Boolean(issue));
415
+ for (const issue of blockingIssues) console.error(`[${name}] ${issue}`);
416
+ for (const issue of audit.canvasRafIssues) console.warn(`[${name}] WARNING ${issue}`);
417
+ return { name, ok: blockingIssues.length === 0 };
313
418
  }
314
419
  function styleLintRoots(target) {
315
420
  const sourceRoots = (0, import_node_fs2.readdirSync)((0, import_node_path2.join)(projectRoot, "src"), {
@@ -318,7 +423,7 @@ function styleLintRoots(target) {
318
423
  return target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? [...sourceRoots, "tests"] : sourceRoots;
319
424
  }
320
425
  async function runAllChecks(target) {
321
- const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactProductTestAlignment()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
426
+ const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactSourceSafety()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
322
427
  const lintRoots = styleLintRoots(target);
323
428
  const [tsgo, biome] = await Promise.all([
324
429
  run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
@@ -23,6 +23,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
23
23
  "useOwnedGameController"
24
24
  ]);
25
25
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
26
+ var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
27
+ "ball",
28
+ "bricks",
29
+ "enemies",
30
+ "entities",
31
+ "entity",
32
+ "elapsedMs",
33
+ "elapsedTimeMs",
34
+ "hoveredCell",
35
+ "paddle",
36
+ "particles",
37
+ "player",
38
+ "projectiles",
39
+ "snapshot",
40
+ "state"
41
+ ]);
26
42
  function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
27
43
  if (!(0, import_node_fs.existsSync)(directory)) return [];
28
44
  const files = [];
@@ -116,6 +132,88 @@ function containsAny(values, expected) {
116
132
  function isRecord(value) {
117
133
  return typeof value === "object" && value !== null;
118
134
  }
135
+ function visitAst(value, visitor) {
136
+ if (Array.isArray(value)) {
137
+ for (const item of value) visitAst(item, visitor);
138
+ return;
139
+ }
140
+ if (!isRecord(value)) return;
141
+ if (typeof value.type === "string") visitor(value);
142
+ for (const child of Object.values(value)) visitAst(child, visitor);
143
+ }
144
+ function identifierName(value) {
145
+ return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
146
+ }
147
+ function memberPath(value) {
148
+ if (!isRecord(value)) return [];
149
+ const identifier = identifierName(value);
150
+ if (identifier) return [identifier];
151
+ if (value.type !== "MemberExpression") return [];
152
+ return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
153
+ }
154
+ function hasRepeatedAnimationFrameCallback(value) {
155
+ const callbackCounts = /* @__PURE__ */ new Map();
156
+ visitAst(value, (node) => {
157
+ if (node.type !== "CallExpression") return;
158
+ if (identifierName(node.callee) !== "requestAnimationFrame") return;
159
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
160
+ const callback = identifierName(arguments_[0]);
161
+ if (!callback) return;
162
+ callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
163
+ });
164
+ return [...callbackCounts.values()].some((count) => count >= 2);
165
+ }
166
+ function reactEffectNames(file, source) {
167
+ const names = /* @__PURE__ */ new Set(["useEffect"]);
168
+ for (const declaration of staticImports(file, source)) {
169
+ if (declaration.moduleRequest.value !== "react") continue;
170
+ for (const entry of declaration.entries) {
171
+ if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
172
+ names.add(entry.localName.value);
173
+ }
174
+ }
175
+ }
176
+ return names;
177
+ }
178
+ function isReactEffectCall(node, effectNames) {
179
+ if (node.type !== "CallExpression") return false;
180
+ const directName = identifierName(node.callee);
181
+ if (directName && effectNames.has(directName)) return true;
182
+ const path = memberPath(node.callee);
183
+ return path.length === 2 && path[1] === "useEffect";
184
+ }
185
+ function canvasRafDependencyIssues(file, source, projectRoot2) {
186
+ const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
187
+ source
188
+ );
189
+ if (!ownsCanvas) return [];
190
+ const issues = [];
191
+ const effectNames = reactEffectNames(file, source);
192
+ visitAst(parseSource(file, source).program, (node) => {
193
+ if (!isReactEffectCall(node, effectNames)) return;
194
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
195
+ if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
196
+ const dependencies = arguments_[1];
197
+ if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
198
+ const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
199
+ const unsafe = elements.filter(
200
+ (element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
201
+ );
202
+ if (unsafe.length === 0) return;
203
+ const labels = unsafe.map((element) => {
204
+ if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
205
+ return "dynamic state";
206
+ }
207
+ return source.slice(element.start, element.end);
208
+ });
209
+ const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
210
+ const path = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
211
+ issues.push(
212
+ `CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
213
+ );
214
+ });
215
+ return issues;
216
+ }
119
217
  function containsObserveProperty(value) {
120
218
  if (Array.isArray(value)) return value.some(containsObserveProperty);
121
219
  if (!isRecord(value)) return false;
@@ -131,17 +229,20 @@ function declaresObserve(file, source) {
131
229
  function auditReactAuthoritativePlaythrough(projectRoot2) {
132
230
  const clockFiles = [];
133
231
  const controllerFiles = [];
232
+ const canvasRafIssues = [];
134
233
  const productionFiles = sourceFiles(projectRoot2);
135
234
  for (const file of productionFiles) {
235
+ const source = (0, import_node_fs.readFileSync)(file, "utf8");
136
236
  const imports = namedImports(
137
237
  file,
138
- (0, import_node_fs.readFileSync)(file, "utf8"),
238
+ source,
139
239
  REACT_RUNTIME_ENTRY
140
240
  );
141
241
  const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
142
242
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
143
243
  if (containsAny(imports, CONTROLLER_IMPORTS))
144
244
  controllerFiles.push(projectPath);
245
+ canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot2));
145
246
  }
146
247
  const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
147
248
  const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
@@ -152,6 +253,7 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
152
253
  (file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
153
254
  ).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
154
255
  const issues = [];
256
+ issues.push(...canvasRafIssues);
155
257
  if (staleExampleTestFiles.length > 0) {
156
258
  issues.push(
157
259
  `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
@@ -165,7 +267,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
165
267
  controllerFiles,
166
268
  productionEntryExists,
167
269
  productionUsesExample,
168
- staleExampleTestFiles
270
+ staleExampleTestFiles,
271
+ canvasRafIssues
169
272
  };
170
273
  }
171
274
  const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
@@ -193,7 +296,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
193
296
  controllerFiles,
194
297
  productionEntryExists,
195
298
  productionUsesExample,
196
- staleExampleTestFiles
299
+ staleExampleTestFiles,
300
+ canvasRafIssues
197
301
  };
198
302
  }
199
303
 
@@ -304,12 +408,13 @@ function checkVitestConfig(target) {
304
408
  }
305
409
  return { name, ok: true };
306
410
  }
307
- function checkReactProductTestAlignment() {
308
- const name = "react-product-test-alignment";
411
+ function checkReactSourceSafety() {
412
+ const name = "react-source-safety";
309
413
  const audit = auditReactAuthoritativePlaythrough(projectRoot);
310
- if (audit.staleExampleTestFiles.length === 0) return { name, ok: true };
311
- console.error(`[${name}] ${audit.issues[0]}`);
312
- return { name, ok: false };
414
+ const blockingIssues = (audit.staleExampleTestFiles.length > 0 ? [audit.issues.find((issue) => issue.includes("runnable product tests still do"))] : []).filter((issue) => Boolean(issue));
415
+ for (const issue of blockingIssues) console.error(`[${name}] ${issue}`);
416
+ for (const issue of audit.canvasRafIssues) console.warn(`[${name}] WARNING ${issue}`);
417
+ return { name, ok: blockingIssues.length === 0 };
313
418
  }
314
419
  function styleLintRoots(target) {
315
420
  const sourceRoots = (0, import_node_fs2.readdirSync)((0, import_node_path2.join)(projectRoot, "src"), {
@@ -318,7 +423,7 @@ function styleLintRoots(target) {
318
423
  return target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? [...sourceRoots, "tests"] : sourceRoots;
319
424
  }
320
425
  async function runAllChecks(target) {
321
- const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactProductTestAlignment()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
426
+ const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactSourceSafety()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
322
427
  const lintRoots = styleLintRoots(target);
323
428
  const [tsgo, biome] = await Promise.all([
324
429
  run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
@@ -86,7 +86,27 @@ function getJSDOMWebGLContext(canvas, options) {
86
86
  }
87
87
 
88
88
  // src/testing/jsdom-canvas.ts
89
- var contexts2 = /* @__PURE__ */ new WeakMap();
89
+ var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
90
+ var registryScope = globalThis;
91
+ var registry = registryScope[registryKey] ??= {
92
+ contexts: /* @__PURE__ */ new WeakMap(),
93
+ drawCallCounts: /* @__PURE__ */ new WeakMap()
94
+ };
95
+ var { contexts: contexts2, drawCallCounts } = registry;
96
+ var DRAW_METHODS = /* @__PURE__ */ new Set([
97
+ "clearRect",
98
+ "drawImage",
99
+ "fill",
100
+ "fillRect",
101
+ "fillText",
102
+ "putImageData",
103
+ "stroke",
104
+ "strokeRect",
105
+ "strokeText"
106
+ ]);
107
+ function getJSDOMCanvasDrawCallCount(canvas) {
108
+ return drawCallCounts.get(canvas) ?? 0;
109
+ }
90
110
  function createCanvasContext(canvas) {
91
111
  const imageData = (width = 1, height = 1) => ({
92
112
  data: new Uint8ClampedArray(width * height * 4),
@@ -127,7 +147,12 @@ function createCanvasContext(canvas) {
127
147
  return () => ({ addColorStop() {
128
148
  } });
129
149
  }
130
- return () => void 0;
150
+ return () => {
151
+ if (typeof property === "string" && DRAW_METHODS.has(property)) {
152
+ drawCallCounts.set(canvas, getJSDOMCanvasDrawCallCount(canvas) + 1);
153
+ }
154
+ return void 0;
155
+ };
131
156
  }
132
157
  });
133
158
  return context;
@@ -142,6 +167,7 @@ function installJSDOMCanvasContext() {
142
167
  if (existing) return existing;
143
168
  const context = createCanvasContext(this);
144
169
  contexts2.set(this, context);
170
+ drawCallCounts.set(this, 0);
145
171
  return context;
146
172
  };
147
173
  }
@@ -1,4 +1,6 @@
1
- export { G as GameClock, a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.mjs';
1
+ import { G as GameClock } from '../game-clock-suUidZdT.mjs';
2
+ export { a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.mjs';
3
+ import { RefObject } from 'react';
2
4
 
3
5
  interface DisposableGameController {
4
6
  destroy(): void;
@@ -31,4 +33,26 @@ declare function useGameController<TController extends SubscribableGameControlle
31
33
  state: ReturnType<TController["snapshot"]>;
32
34
  };
33
35
 
34
- export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
36
+ interface GameCanvasFrame<TState> {
37
+ canvas: HTMLCanvasElement;
38
+ context: CanvasRenderingContext2D;
39
+ state: TState;
40
+ timestamp: number;
41
+ deltaMs: number;
42
+ }
43
+ interface UseGameCanvasOptions<TState> {
44
+ canvasRef: RefObject<HTMLCanvasElement | null>;
45
+ read: () => TState;
46
+ draw: (frame: GameCanvasFrame<TState>) => void;
47
+ clock?: GameClock;
48
+ maxDeltaMs?: number;
49
+ }
50
+ /**
51
+ * Convenience lifecycle for a plain HTML 2D Canvas. This is not the Devkit's renderer
52
+ * abstraction: WebGL/R3F/Pixi or another tool should use its own lifecycle instead.
53
+ * The component using this hook must render the attached canvas on the same mount.
54
+ * Pass the same injected clock used by the game Controller in deterministic tests.
55
+ */
56
+ declare function useGameCanvas<TState>({ canvasRef, read, draw, clock, maxDeltaMs, }: UseGameCanvasOptions<TState>): void;
57
+
58
+ export { type DisposableGameController, type GameCanvasFrame, GameClock, type SubscribableGameController, type UseGameCanvasOptions, useGameCanvas, useGameController, useOwnedGameController };
@@ -1,4 +1,6 @@
1
- export { G as GameClock, a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.js';
1
+ import { G as GameClock } from '../game-clock-suUidZdT.js';
2
+ export { a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.js';
3
+ import { RefObject } from 'react';
2
4
 
3
5
  interface DisposableGameController {
4
6
  destroy(): void;
@@ -31,4 +33,26 @@ declare function useGameController<TController extends SubscribableGameControlle
31
33
  state: ReturnType<TController["snapshot"]>;
32
34
  };
33
35
 
34
- export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
36
+ interface GameCanvasFrame<TState> {
37
+ canvas: HTMLCanvasElement;
38
+ context: CanvasRenderingContext2D;
39
+ state: TState;
40
+ timestamp: number;
41
+ deltaMs: number;
42
+ }
43
+ interface UseGameCanvasOptions<TState> {
44
+ canvasRef: RefObject<HTMLCanvasElement | null>;
45
+ read: () => TState;
46
+ draw: (frame: GameCanvasFrame<TState>) => void;
47
+ clock?: GameClock;
48
+ maxDeltaMs?: number;
49
+ }
50
+ /**
51
+ * Convenience lifecycle for a plain HTML 2D Canvas. This is not the Devkit's renderer
52
+ * abstraction: WebGL/R3F/Pixi or another tool should use its own lifecycle instead.
53
+ * The component using this hook must render the attached canvas on the same mount.
54
+ * Pass the same injected clock used by the game Controller in deterministic tests.
55
+ */
56
+ declare function useGameCanvas<TState>({ canvasRef, read, draw, clock, maxDeltaMs, }: UseGameCanvasOptions<TState>): void;
57
+
58
+ export { type DisposableGameController, type GameCanvasFrame, GameClock, type SubscribableGameController, type UseGameCanvasOptions, useGameCanvas, useGameController, useOwnedGameController };
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var react_exports = {};
22
22
  __export(react_exports, {
23
23
  browserGameClock: () => browserGameClock,
24
+ useGameCanvas: () => useGameCanvas,
24
25
  useGameController: () => useGameController,
25
26
  useOwnedGameController: () => useOwnedGameController
26
27
  });
@@ -109,9 +110,59 @@ function useSnapshotIntegrityCheck(game) {
109
110
  });
110
111
  }, [game]);
111
112
  }
113
+
114
+ // src/react/use-game-canvas.ts
115
+ var import_react3 = require("react");
116
+ function useGameCanvas({
117
+ canvasRef,
118
+ read,
119
+ draw,
120
+ clock = browserGameClock,
121
+ maxDeltaMs = 50
122
+ }) {
123
+ const readRef = (0, import_react3.useRef)(read);
124
+ const drawRef = (0, import_react3.useRef)(draw);
125
+ readRef.current = read;
126
+ drawRef.current = draw;
127
+ (0, import_react3.useEffect)(() => {
128
+ const canvas = canvasRef.current;
129
+ const context = canvas?.getContext("2d");
130
+ if (!canvas || !context) {
131
+ throw codedError(
132
+ "CANVAS_2D_CONTEXT_UNAVAILABLE",
133
+ "useGameCanvas found no 2D context: the attached <canvas> must be rendered on the same mount as the hook (never conditionally or one tick later). Mount the hook and its <canvas> together in one child component."
134
+ );
135
+ }
136
+ let active = true;
137
+ let frameId = 0;
138
+ let previousTimestamp = clock.now();
139
+ const renderFrame = (timestamp) => {
140
+ if (!active) return;
141
+ const deltaMs = Math.max(
142
+ 0,
143
+ Math.min(maxDeltaMs, timestamp - previousTimestamp)
144
+ );
145
+ previousTimestamp = timestamp;
146
+ drawRef.current({
147
+ canvas,
148
+ context,
149
+ state: readRef.current(),
150
+ timestamp,
151
+ deltaMs
152
+ });
153
+ frameId = clock.requestFrame(renderFrame);
154
+ };
155
+ frameId = clock.requestFrame(renderFrame);
156
+ return () => {
157
+ active = false;
158
+ clock.cancelFrame(frameId);
159
+ };
160
+ }, [canvasRef, clock, maxDeltaMs]);
161
+ }
112
162
  // Annotate the CommonJS export names for ESM import in node:
113
163
  0 && (module.exports = {
114
164
  browserGameClock,
165
+ useGameCanvas,
115
166
  useGameController,
116
167
  useOwnedGameController
117
168
  });
@@ -81,8 +81,58 @@ function useSnapshotIntegrityCheck(game) {
81
81
  });
82
82
  }, [game]);
83
83
  }
84
+
85
+ // src/react/use-game-canvas.ts
86
+ import { useEffect as useEffect3, useRef as useRef3 } from "react";
87
+ function useGameCanvas({
88
+ canvasRef,
89
+ read,
90
+ draw,
91
+ clock = browserGameClock,
92
+ maxDeltaMs = 50
93
+ }) {
94
+ const readRef = useRef3(read);
95
+ const drawRef = useRef3(draw);
96
+ readRef.current = read;
97
+ drawRef.current = draw;
98
+ useEffect3(() => {
99
+ const canvas = canvasRef.current;
100
+ const context = canvas?.getContext("2d");
101
+ if (!canvas || !context) {
102
+ throw codedError(
103
+ "CANVAS_2D_CONTEXT_UNAVAILABLE",
104
+ "useGameCanvas found no 2D context: the attached <canvas> must be rendered on the same mount as the hook (never conditionally or one tick later). Mount the hook and its <canvas> together in one child component."
105
+ );
106
+ }
107
+ let active = true;
108
+ let frameId = 0;
109
+ let previousTimestamp = clock.now();
110
+ const renderFrame = (timestamp) => {
111
+ if (!active) return;
112
+ const deltaMs = Math.max(
113
+ 0,
114
+ Math.min(maxDeltaMs, timestamp - previousTimestamp)
115
+ );
116
+ previousTimestamp = timestamp;
117
+ drawRef.current({
118
+ canvas,
119
+ context,
120
+ state: readRef.current(),
121
+ timestamp,
122
+ deltaMs
123
+ });
124
+ frameId = clock.requestFrame(renderFrame);
125
+ };
126
+ frameId = clock.requestFrame(renderFrame);
127
+ return () => {
128
+ active = false;
129
+ clock.cancelFrame(frameId);
130
+ };
131
+ }, [canvasRef, clock, maxDeltaMs]);
132
+ }
84
133
  export {
85
134
  browserGameClock,
135
+ useGameCanvas,
86
136
  useGameController,
87
137
  useOwnedGameController
88
138
  };
@@ -22,6 +22,9 @@ declare class ManualGameClock implements GameClock {
22
22
  pendingTimerCount(): number;
23
23
  }
24
24
 
25
+ /** Return recorded 2D drawing calls in JSDOM; real browser canvases return zero. */
26
+ declare function getJSDOMCanvasDrawCallCount(canvas: HTMLCanvasElement): number;
27
+
25
28
  interface ReactFailureEntry {
26
29
  code: string;
27
30
  message: string;
@@ -166,4 +169,4 @@ declare module "vitest" {
166
169
  }
167
170
  }
168
171
 
169
- export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
172
+ export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, getJSDOMCanvasDrawCallCount, playthroughTest };
@@ -22,6 +22,9 @@ declare class ManualGameClock implements GameClock {
22
22
  pendingTimerCount(): number;
23
23
  }
24
24
 
25
+ /** Return recorded 2D drawing calls in JSDOM; real browser canvases return zero. */
26
+ declare function getJSDOMCanvasDrawCallCount(canvas: HTMLCanvasElement): number;
27
+
25
28
  interface ReactFailureEntry {
26
29
  code: string;
27
30
  message: string;
@@ -166,4 +169,4 @@ declare module "vitest" {
166
169
  }
167
170
  }
168
171
 
169
- export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
172
+ export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, getJSDOMCanvasDrawCallCount, playthroughTest };
@@ -32,6 +32,7 @@ var testing_exports = {};
32
32
  __export(testing_exports, {
33
33
  ManualGameClock: () => ManualGameClock,
34
34
  auditReactPlaythroughRun: () => auditReactPlaythroughRun,
35
+ getJSDOMCanvasDrawCallCount: () => getJSDOMCanvasDrawCallCount,
35
36
  playthroughTest: () => playthroughTest
36
37
  });
37
38
  module.exports = __toCommonJS(testing_exports);
@@ -91,6 +92,18 @@ var ManualGameClock = class {
91
92
  }
92
93
  };
93
94
 
95
+ // src/testing/jsdom-canvas.ts
96
+ var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
97
+ var registryScope = globalThis;
98
+ var registry = registryScope[registryKey] ??= {
99
+ contexts: /* @__PURE__ */ new WeakMap(),
100
+ drawCallCounts: /* @__PURE__ */ new WeakMap()
101
+ };
102
+ var { contexts, drawCallCounts } = registry;
103
+ function getJSDOMCanvasDrawCallCount(canvas) {
104
+ return drawCallCounts.get(canvas) ?? 0;
105
+ }
106
+
94
107
  // src/react/react-playthrough.ts
95
108
  var import_react2 = require("@testing-library/react");
96
109
  var import_user_event = __toESM(require("@testing-library/user-event"));
@@ -775,5 +788,6 @@ function auditReactPlaythroughRun(tests) {
775
788
  0 && (module.exports = {
776
789
  ManualGameClock,
777
790
  auditReactPlaythroughRun,
791
+ getJSDOMCanvasDrawCallCount,
778
792
  playthroughTest
779
793
  });
@@ -53,6 +53,18 @@ var ManualGameClock = class {
53
53
  }
54
54
  };
55
55
 
56
+ // src/testing/jsdom-canvas.ts
57
+ var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
58
+ var registryScope = globalThis;
59
+ var registry = registryScope[registryKey] ??= {
60
+ contexts: /* @__PURE__ */ new WeakMap(),
61
+ drawCallCounts: /* @__PURE__ */ new WeakMap()
62
+ };
63
+ var { contexts, drawCallCounts } = registry;
64
+ function getJSDOMCanvasDrawCallCount(canvas) {
65
+ return drawCallCounts.get(canvas) ?? 0;
66
+ }
67
+
56
68
  // src/react/react-playthrough.ts
57
69
  import { render } from "@testing-library/react";
58
70
  import userEvent from "@testing-library/user-event";
@@ -736,5 +748,6 @@ function auditReactPlaythroughRun(tests) {
736
748
  export {
737
749
  ManualGameClock,
738
750
  auditReactPlaythroughRun,
751
+ getJSDOMCanvasDrawCallCount,
739
752
  playthroughTest
740
753
  };
@@ -1059,6 +1059,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
1059
1059
  "useOwnedGameController"
1060
1060
  ]);
1061
1061
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
1062
+ var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
1063
+ "ball",
1064
+ "bricks",
1065
+ "enemies",
1066
+ "entities",
1067
+ "entity",
1068
+ "elapsedMs",
1069
+ "elapsedTimeMs",
1070
+ "hoveredCell",
1071
+ "paddle",
1072
+ "particles",
1073
+ "player",
1074
+ "projectiles",
1075
+ "snapshot",
1076
+ "state"
1077
+ ]);
1062
1078
  function runnableTestFiles(root, directory = (0, import_node_path2.join)(root, "tests")) {
1063
1079
  if (!(0, import_node_fs.existsSync)(directory)) return [];
1064
1080
  const files = [];
@@ -1152,6 +1168,88 @@ function containsAny(values, expected) {
1152
1168
  function isRecord(value) {
1153
1169
  return typeof value === "object" && value !== null;
1154
1170
  }
1171
+ function visitAst(value, visitor) {
1172
+ if (Array.isArray(value)) {
1173
+ for (const item of value) visitAst(item, visitor);
1174
+ return;
1175
+ }
1176
+ if (!isRecord(value)) return;
1177
+ if (typeof value.type === "string") visitor(value);
1178
+ for (const child of Object.values(value)) visitAst(child, visitor);
1179
+ }
1180
+ function identifierName(value) {
1181
+ return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
1182
+ }
1183
+ function memberPath(value) {
1184
+ if (!isRecord(value)) return [];
1185
+ const identifier = identifierName(value);
1186
+ if (identifier) return [identifier];
1187
+ if (value.type !== "MemberExpression") return [];
1188
+ return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
1189
+ }
1190
+ function hasRepeatedAnimationFrameCallback(value) {
1191
+ const callbackCounts = /* @__PURE__ */ new Map();
1192
+ visitAst(value, (node) => {
1193
+ if (node.type !== "CallExpression") return;
1194
+ if (identifierName(node.callee) !== "requestAnimationFrame") return;
1195
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
1196
+ const callback = identifierName(arguments_[0]);
1197
+ if (!callback) return;
1198
+ callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
1199
+ });
1200
+ return [...callbackCounts.values()].some((count) => count >= 2);
1201
+ }
1202
+ function reactEffectNames(file, source) {
1203
+ const names = /* @__PURE__ */ new Set(["useEffect"]);
1204
+ for (const declaration of staticImports(file, source)) {
1205
+ if (declaration.moduleRequest.value !== "react") continue;
1206
+ for (const entry of declaration.entries) {
1207
+ if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
1208
+ names.add(entry.localName.value);
1209
+ }
1210
+ }
1211
+ }
1212
+ return names;
1213
+ }
1214
+ function isReactEffectCall(node, effectNames) {
1215
+ if (node.type !== "CallExpression") return false;
1216
+ const directName = identifierName(node.callee);
1217
+ if (directName && effectNames.has(directName)) return true;
1218
+ const path = memberPath(node.callee);
1219
+ return path.length === 2 && path[1] === "useEffect";
1220
+ }
1221
+ function canvasRafDependencyIssues(file, source, projectRoot) {
1222
+ const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
1223
+ source
1224
+ );
1225
+ if (!ownsCanvas) return [];
1226
+ const issues = [];
1227
+ const effectNames = reactEffectNames(file, source);
1228
+ visitAst(parseSource(file, source).program, (node) => {
1229
+ if (!isReactEffectCall(node, effectNames)) return;
1230
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
1231
+ if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
1232
+ const dependencies = arguments_[1];
1233
+ if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
1234
+ const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
1235
+ const unsafe = elements.filter(
1236
+ (element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
1237
+ );
1238
+ if (unsafe.length === 0) return;
1239
+ const labels = unsafe.map((element) => {
1240
+ if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
1241
+ return "dynamic state";
1242
+ }
1243
+ return source.slice(element.start, element.end);
1244
+ });
1245
+ const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
1246
+ const path = (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/");
1247
+ issues.push(
1248
+ `CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
1249
+ );
1250
+ });
1251
+ return issues;
1252
+ }
1155
1253
  function containsObserveProperty(value) {
1156
1254
  if (Array.isArray(value)) return value.some(containsObserveProperty);
1157
1255
  if (!isRecord(value)) return false;
@@ -1167,17 +1265,20 @@ function declaresObserve(file, source) {
1167
1265
  function auditReactAuthoritativePlaythrough(projectRoot) {
1168
1266
  const clockFiles = [];
1169
1267
  const controllerFiles = [];
1268
+ const canvasRafIssues = [];
1170
1269
  const productionFiles = sourceFiles(projectRoot);
1171
1270
  for (const file of productionFiles) {
1271
+ const source = (0, import_node_fs.readFileSync)(file, "utf8");
1172
1272
  const imports = namedImports(
1173
1273
  file,
1174
- (0, import_node_fs.readFileSync)(file, "utf8"),
1274
+ source,
1175
1275
  REACT_RUNTIME_ENTRY
1176
1276
  );
1177
1277
  const projectPath = (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/");
1178
1278
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
1179
1279
  if (containsAny(imports, CONTROLLER_IMPORTS))
1180
1280
  controllerFiles.push(projectPath);
1281
+ canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot));
1181
1282
  }
1182
1283
  const appPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_APP);
1183
1284
  const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
@@ -1188,6 +1289,7 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
1188
1289
  (file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
1189
1290
  ).map((file) => (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/"));
1190
1291
  const issues = [];
1292
+ issues.push(...canvasRafIssues);
1191
1293
  if (staleExampleTestFiles.length > 0) {
1192
1294
  issues.push(
1193
1295
  `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
@@ -1201,7 +1303,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
1201
1303
  controllerFiles,
1202
1304
  productionEntryExists,
1203
1305
  productionUsesExample,
1204
- staleExampleTestFiles
1306
+ staleExampleTestFiles,
1307
+ canvasRafIssues
1205
1308
  };
1206
1309
  }
1207
1310
  const testPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
@@ -1229,7 +1332,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
1229
1332
  controllerFiles,
1230
1333
  productionEntryExists,
1231
1334
  productionUsesExample,
1232
- staleExampleTestFiles
1335
+ staleExampleTestFiles,
1336
+ canvasRafIssues
1233
1337
  };
1234
1338
  }
1235
1339
 
@@ -1534,12 +1638,6 @@ function defineReactGameVitestConfig(options) {
1534
1638
  functions: 50,
1535
1639
  statements: 50,
1536
1640
  branches: 40
1537
- },
1538
- "src/game/runtime/**/*.{ts,tsx}": {
1539
- lines: 40,
1540
- functions: 40,
1541
- statements: 40,
1542
- branches: 30
1543
1641
  }
1544
1642
  }
1545
1643
  },
@@ -1025,6 +1025,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
1025
1025
  "useOwnedGameController"
1026
1026
  ]);
1027
1027
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
1028
+ var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
1029
+ "ball",
1030
+ "bricks",
1031
+ "enemies",
1032
+ "entities",
1033
+ "entity",
1034
+ "elapsedMs",
1035
+ "elapsedTimeMs",
1036
+ "hoveredCell",
1037
+ "paddle",
1038
+ "particles",
1039
+ "player",
1040
+ "projectiles",
1041
+ "snapshot",
1042
+ "state"
1043
+ ]);
1028
1044
  function runnableTestFiles(root, directory = join(root, "tests")) {
1029
1045
  if (!existsSync(directory)) return [];
1030
1046
  const files = [];
@@ -1118,6 +1134,88 @@ function containsAny(values, expected) {
1118
1134
  function isRecord(value) {
1119
1135
  return typeof value === "object" && value !== null;
1120
1136
  }
1137
+ function visitAst(value, visitor) {
1138
+ if (Array.isArray(value)) {
1139
+ for (const item of value) visitAst(item, visitor);
1140
+ return;
1141
+ }
1142
+ if (!isRecord(value)) return;
1143
+ if (typeof value.type === "string") visitor(value);
1144
+ for (const child of Object.values(value)) visitAst(child, visitor);
1145
+ }
1146
+ function identifierName(value) {
1147
+ return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
1148
+ }
1149
+ function memberPath(value) {
1150
+ if (!isRecord(value)) return [];
1151
+ const identifier = identifierName(value);
1152
+ if (identifier) return [identifier];
1153
+ if (value.type !== "MemberExpression") return [];
1154
+ return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
1155
+ }
1156
+ function hasRepeatedAnimationFrameCallback(value) {
1157
+ const callbackCounts = /* @__PURE__ */ new Map();
1158
+ visitAst(value, (node) => {
1159
+ if (node.type !== "CallExpression") return;
1160
+ if (identifierName(node.callee) !== "requestAnimationFrame") return;
1161
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
1162
+ const callback = identifierName(arguments_[0]);
1163
+ if (!callback) return;
1164
+ callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
1165
+ });
1166
+ return [...callbackCounts.values()].some((count) => count >= 2);
1167
+ }
1168
+ function reactEffectNames(file, source) {
1169
+ const names = /* @__PURE__ */ new Set(["useEffect"]);
1170
+ for (const declaration of staticImports(file, source)) {
1171
+ if (declaration.moduleRequest.value !== "react") continue;
1172
+ for (const entry of declaration.entries) {
1173
+ if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
1174
+ names.add(entry.localName.value);
1175
+ }
1176
+ }
1177
+ }
1178
+ return names;
1179
+ }
1180
+ function isReactEffectCall(node, effectNames) {
1181
+ if (node.type !== "CallExpression") return false;
1182
+ const directName = identifierName(node.callee);
1183
+ if (directName && effectNames.has(directName)) return true;
1184
+ const path = memberPath(node.callee);
1185
+ return path.length === 2 && path[1] === "useEffect";
1186
+ }
1187
+ function canvasRafDependencyIssues(file, source, projectRoot) {
1188
+ const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
1189
+ source
1190
+ );
1191
+ if (!ownsCanvas) return [];
1192
+ const issues = [];
1193
+ const effectNames = reactEffectNames(file, source);
1194
+ visitAst(parseSource(file, source).program, (node) => {
1195
+ if (!isReactEffectCall(node, effectNames)) return;
1196
+ const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
1197
+ if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
1198
+ const dependencies = arguments_[1];
1199
+ if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
1200
+ const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
1201
+ const unsafe = elements.filter(
1202
+ (element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
1203
+ );
1204
+ if (unsafe.length === 0) return;
1205
+ const labels = unsafe.map((element) => {
1206
+ if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
1207
+ return "dynamic state";
1208
+ }
1209
+ return source.slice(element.start, element.end);
1210
+ });
1211
+ const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
1212
+ const path = relative2(projectRoot, file).replaceAll("\\", "/");
1213
+ issues.push(
1214
+ `CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
1215
+ );
1216
+ });
1217
+ return issues;
1218
+ }
1121
1219
  function containsObserveProperty(value) {
1122
1220
  if (Array.isArray(value)) return value.some(containsObserveProperty);
1123
1221
  if (!isRecord(value)) return false;
@@ -1133,17 +1231,20 @@ function declaresObserve(file, source) {
1133
1231
  function auditReactAuthoritativePlaythrough(projectRoot) {
1134
1232
  const clockFiles = [];
1135
1233
  const controllerFiles = [];
1234
+ const canvasRafIssues = [];
1136
1235
  const productionFiles = sourceFiles(projectRoot);
1137
1236
  for (const file of productionFiles) {
1237
+ const source = readFileSync(file, "utf8");
1138
1238
  const imports = namedImports(
1139
1239
  file,
1140
- readFileSync(file, "utf8"),
1240
+ source,
1141
1241
  REACT_RUNTIME_ENTRY
1142
1242
  );
1143
1243
  const projectPath = relative2(projectRoot, file).replaceAll("\\", "/");
1144
1244
  if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
1145
1245
  if (containsAny(imports, CONTROLLER_IMPORTS))
1146
1246
  controllerFiles.push(projectPath);
1247
+ canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot));
1147
1248
  }
1148
1249
  const appPath = join(projectRoot, PRODUCTION_APP);
1149
1250
  const productionEntryExists = existsSync(appPath);
@@ -1154,6 +1255,7 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
1154
1255
  (file) => importsExampleAlias(file, readFileSync(file, "utf8"))
1155
1256
  ).map((file) => relative2(projectRoot, file).replaceAll("\\", "/"));
1156
1257
  const issues = [];
1258
+ issues.push(...canvasRafIssues);
1157
1259
  if (staleExampleTestFiles.length > 0) {
1158
1260
  issues.push(
1159
1261
  `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
@@ -1167,7 +1269,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
1167
1269
  controllerFiles,
1168
1270
  productionEntryExists,
1169
1271
  productionUsesExample,
1170
- staleExampleTestFiles
1272
+ staleExampleTestFiles,
1273
+ canvasRafIssues
1171
1274
  };
1172
1275
  }
1173
1276
  const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
@@ -1195,7 +1298,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
1195
1298
  controllerFiles,
1196
1299
  productionEntryExists,
1197
1300
  productionUsesExample,
1198
- staleExampleTestFiles
1301
+ staleExampleTestFiles,
1302
+ canvasRafIssues
1199
1303
  };
1200
1304
  }
1201
1305
 
@@ -1500,12 +1604,6 @@ function defineReactGameVitestConfig(options) {
1500
1604
  functions: 50,
1501
1605
  statements: 50,
1502
1606
  branches: 40
1503
- },
1504
- "src/game/runtime/**/*.{ts,tsx}": {
1505
- lines: 40,
1506
- functions: 40,
1507
- statements: 40,
1508
- branches: 30
1509
1607
  }
1510
1608
  }
1511
1609
  },
@@ -269,7 +269,27 @@ function getJSDOMWebGLContext(canvas, options) {
269
269
  }
270
270
 
271
271
  // src/testing/jsdom-canvas.ts
272
- var contexts2 = /* @__PURE__ */ new WeakMap();
272
+ var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
273
+ var registryScope = globalThis;
274
+ var registry = registryScope[registryKey] ??= {
275
+ contexts: /* @__PURE__ */ new WeakMap(),
276
+ drawCallCounts: /* @__PURE__ */ new WeakMap()
277
+ };
278
+ var { contexts: contexts2, drawCallCounts } = registry;
279
+ var DRAW_METHODS = /* @__PURE__ */ new Set([
280
+ "clearRect",
281
+ "drawImage",
282
+ "fill",
283
+ "fillRect",
284
+ "fillText",
285
+ "putImageData",
286
+ "stroke",
287
+ "strokeRect",
288
+ "strokeText"
289
+ ]);
290
+ function getJSDOMCanvasDrawCallCount(canvas) {
291
+ return drawCallCounts.get(canvas) ?? 0;
292
+ }
273
293
  function createCanvasContext(canvas) {
274
294
  const imageData = (width = 1, height = 1) => ({
275
295
  data: new Uint8ClampedArray(width * height * 4),
@@ -310,7 +330,12 @@ function createCanvasContext(canvas) {
310
330
  return () => ({ addColorStop() {
311
331
  } });
312
332
  }
313
- return () => void 0;
333
+ return () => {
334
+ if (typeof property === "string" && DRAW_METHODS.has(property)) {
335
+ drawCallCounts.set(canvas, getJSDOMCanvasDrawCallCount(canvas) + 1);
336
+ }
337
+ return void 0;
338
+ };
314
339
  }
315
340
  });
316
341
  return context;
@@ -325,6 +350,7 @@ function installJSDOMCanvasContext() {
325
350
  if (existing) return existing;
326
351
  const context = createCanvasContext(this);
327
352
  contexts2.set(this, context);
353
+ drawCallCounts.set(this, 0);
328
354
  return context;
329
355
  };
330
356
  }
@@ -267,7 +267,27 @@ function getJSDOMWebGLContext(canvas, options) {
267
267
  }
268
268
 
269
269
  // src/testing/jsdom-canvas.ts
270
- var contexts2 = /* @__PURE__ */ new WeakMap();
270
+ var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
271
+ var registryScope = globalThis;
272
+ var registry = registryScope[registryKey] ??= {
273
+ contexts: /* @__PURE__ */ new WeakMap(),
274
+ drawCallCounts: /* @__PURE__ */ new WeakMap()
275
+ };
276
+ var { contexts: contexts2, drawCallCounts } = registry;
277
+ var DRAW_METHODS = /* @__PURE__ */ new Set([
278
+ "clearRect",
279
+ "drawImage",
280
+ "fill",
281
+ "fillRect",
282
+ "fillText",
283
+ "putImageData",
284
+ "stroke",
285
+ "strokeRect",
286
+ "strokeText"
287
+ ]);
288
+ function getJSDOMCanvasDrawCallCount(canvas) {
289
+ return drawCallCounts.get(canvas) ?? 0;
290
+ }
271
291
  function createCanvasContext(canvas) {
272
292
  const imageData = (width = 1, height = 1) => ({
273
293
  data: new Uint8ClampedArray(width * height * 4),
@@ -308,7 +328,12 @@ function createCanvasContext(canvas) {
308
328
  return () => ({ addColorStop() {
309
329
  } });
310
330
  }
311
- return () => void 0;
331
+ return () => {
332
+ if (typeof property === "string" && DRAW_METHODS.has(property)) {
333
+ drawCallCounts.set(canvas, getJSDOMCanvasDrawCallCount(canvas) + 1);
334
+ }
335
+ return void 0;
336
+ };
312
337
  }
313
338
  });
314
339
  return context;
@@ -323,6 +348,7 @@ function installJSDOMCanvasContext() {
323
348
  if (existing) return existing;
324
349
  const context = createCanvasContext(this);
325
350
  contexts2.set(this, context);
351
+ drawCallCounts.set(this, 0);
326
352
  return context;
327
353
  };
328
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",