@vgai/engine 0.2.0 → 0.3.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.
Files changed (541) hide show
  1. package/README.md +3 -1
  2. package/dist/adapter/authoring.d.ts +404 -0
  3. package/dist/adapter/authoring.d.ts.map +1 -0
  4. package/dist/adapter/authoring.js +22 -0
  5. package/dist/adapter/colyseus-networking-adapter.d.ts +43 -0
  6. package/dist/adapter/colyseus-networking-adapter.d.ts.map +1 -0
  7. package/dist/adapter/colyseus-networking-adapter.js +38 -0
  8. package/dist/adapter/first-party-systems.d.ts +38 -0
  9. package/dist/adapter/first-party-systems.d.ts.map +1 -0
  10. package/dist/adapter/first-party-systems.js +77 -0
  11. package/dist/adapter/game-adapter.d.ts +133 -0
  12. package/dist/adapter/game-adapter.d.ts.map +1 -0
  13. package/dist/adapter/game-adapter.js +10 -0
  14. package/dist/adapter/host-context.d.ts +73 -0
  15. package/dist/adapter/host-context.d.ts.map +1 -0
  16. package/dist/adapter/host-context.js +16 -0
  17. package/dist/adapter/index.d.ts +22 -0
  18. package/dist/adapter/index.d.ts.map +1 -0
  19. package/dist/adapter/index.js +15 -0
  20. package/dist/adapter/ingest/game-contract.d.ts +53 -0
  21. package/dist/adapter/ingest/game-contract.d.ts.map +1 -0
  22. package/dist/adapter/ingest/game-contract.js +30 -0
  23. package/dist/adapter/ingest/overlay-applier.d.ts +118 -0
  24. package/dist/adapter/ingest/overlay-applier.d.ts.map +1 -0
  25. package/dist/adapter/ingest/overlay-applier.js +132 -0
  26. package/dist/adapter/ingest/overlay-apply.d.ts +60 -0
  27. package/dist/adapter/ingest/overlay-apply.d.ts.map +1 -0
  28. package/dist/adapter/ingest/overlay-apply.js +101 -0
  29. package/dist/adapter/ingest/overlay-file.d.ts +79 -0
  30. package/dist/adapter/ingest/overlay-file.d.ts.map +1 -0
  31. package/dist/adapter/ingest/overlay-file.js +66 -0
  32. package/dist/adapter/ingest/overlay-report.d.ts +113 -0
  33. package/dist/adapter/ingest/overlay-report.d.ts.map +1 -0
  34. package/dist/adapter/ingest/overlay-report.js +103 -0
  35. package/dist/adapter/ingest/scene-capture.d.ts +97 -0
  36. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -0
  37. package/dist/adapter/ingest/scene-capture.js +248 -0
  38. package/dist/adapter/ingest/upstream-pin.d.ts +47 -0
  39. package/dist/adapter/ingest/upstream-pin.d.ts.map +1 -0
  40. package/dist/adapter/ingest/upstream-pin.js +50 -0
  41. package/dist/adapter/loop-gate-report.d.ts +34 -0
  42. package/dist/adapter/loop-gate-report.d.ts.map +1 -0
  43. package/dist/adapter/loop-gate-report.js +37 -0
  44. package/dist/adapter/rapier-physics-adapter.d.ts +12 -0
  45. package/dist/adapter/rapier-physics-adapter.d.ts.map +1 -0
  46. package/dist/adapter/rapier-physics-adapter.js +46 -0
  47. package/dist/adapter/system-adapter.d.ts +133 -0
  48. package/dist/adapter/system-adapter.d.ts.map +1 -0
  49. package/dist/adapter/system-adapter.js +13 -0
  50. package/dist/adapter/transform.d.ts +18 -0
  51. package/dist/adapter/transform.d.ts.map +1 -0
  52. package/dist/adapter/transform.js +1 -0
  53. package/dist/adapter/vgai-scene-game-adapter.d.ts +116 -0
  54. package/dist/adapter/vgai-scene-game-adapter.d.ts.map +1 -0
  55. package/dist/adapter/vgai-scene-game-adapter.js +714 -0
  56. package/dist/adapter/world-kind.d.ts +30 -0
  57. package/dist/adapter/world-kind.d.ts.map +1 -0
  58. package/dist/adapter/world-kind.js +18 -0
  59. package/dist/ai/navigation.d.ts +64 -0
  60. package/dist/ai/navigation.d.ts.map +1 -0
  61. package/dist/ai/navigation.js +133 -0
  62. package/dist/animation/anim-graph-types.d.ts +25 -0
  63. package/dist/animation/anim-graph-types.d.ts.map +1 -0
  64. package/dist/animation/anim-graph-types.js +1 -0
  65. package/dist/animation/animation-clock.d.ts +213 -0
  66. package/dist/animation/animation-clock.d.ts.map +1 -0
  67. package/dist/animation/animation-clock.js +301 -0
  68. package/dist/animation/blend-node.d.ts +22 -0
  69. package/dist/animation/blend-node.d.ts.map +1 -0
  70. package/dist/animation/blend-node.js +97 -0
  71. package/dist/animation/camera-ownership.d.ts +250 -0
  72. package/dist/animation/camera-ownership.d.ts.map +1 -0
  73. package/dist/animation/camera-ownership.js +169 -0
  74. package/dist/animation/cinematic-cues.d.ts +200 -0
  75. package/dist/animation/cinematic-cues.d.ts.map +1 -0
  76. package/dist/animation/cinematic-cues.js +213 -0
  77. package/dist/animation/clip-map.d.ts +12 -0
  78. package/dist/animation/clip-map.d.ts.map +1 -0
  79. package/dist/animation/clip-map.js +37 -0
  80. package/dist/animation/gsap-registration.d.ts +119 -0
  81. package/dist/animation/gsap-registration.d.ts.map +1 -0
  82. package/dist/animation/gsap-registration.js +92 -0
  83. package/dist/animation/theatre-clock-binding.d.ts +89 -0
  84. package/dist/animation/theatre-clock-binding.d.ts.map +1 -0
  85. package/dist/animation/theatre-clock-binding.js +29 -0
  86. package/dist/animation/theatre-director.d.ts +242 -0
  87. package/dist/animation/theatre-director.d.ts.map +1 -0
  88. package/dist/animation/theatre-director.js +235 -0
  89. package/dist/animation/theatre-object-binding.d.ts +305 -0
  90. package/dist/animation/theatre-object-binding.d.ts.map +1 -0
  91. package/dist/animation/theatre-object-binding.js +351 -0
  92. package/dist/animation/xstate-animation-binding.d.ts +111 -0
  93. package/dist/animation/xstate-animation-binding.d.ts.map +1 -0
  94. package/dist/animation/xstate-animation-binding.js +307 -0
  95. package/dist/animation/xstate-animation-meta.d.ts +228 -0
  96. package/dist/animation/xstate-animation-meta.d.ts.map +1 -0
  97. package/dist/animation/xstate-animation-meta.js +221 -0
  98. package/dist/assets.d.ts +26 -0
  99. package/dist/assets.d.ts.map +1 -0
  100. package/dist/assets.js +55 -0
  101. package/dist/audio/index.d.ts +4 -0
  102. package/dist/audio/index.d.ts.map +1 -0
  103. package/dist/audio/index.js +24 -0
  104. package/dist/audio/tone-clock-binding.d.ts +75 -0
  105. package/dist/audio/tone-clock-binding.d.ts.map +1 -0
  106. package/dist/audio/tone-clock-binding.js +29 -0
  107. package/dist/audio/tone-context.d.ts +114 -0
  108. package/dist/audio/tone-context.d.ts.map +1 -0
  109. package/dist/audio/tone-context.js +39 -0
  110. package/dist/audio/tone-offline-render.d.ts +90 -0
  111. package/dist/audio/tone-offline-render.d.ts.map +1 -0
  112. package/dist/audio/tone-offline-render.js +56 -0
  113. package/dist/audio/wav-encode.d.ts +38 -0
  114. package/dist/audio/wav-encode.d.ts.map +1 -0
  115. package/dist/audio/wav-encode.js +97 -0
  116. package/dist/core/game-loop.d.ts +21 -0
  117. package/dist/core/game-loop.d.ts.map +1 -0
  118. package/dist/core/game-loop.js +130 -0
  119. package/dist/core/system-runner.d.ts +120 -0
  120. package/dist/core/system-runner.d.ts.map +1 -0
  121. package/dist/core/system-runner.js +286 -0
  122. package/dist/core/types.d.ts +58 -0
  123. package/dist/core/types.d.ts.map +1 -0
  124. package/dist/core/types.js +25 -0
  125. package/dist/data/data-asset.d.ts +101 -0
  126. package/dist/data/data-asset.d.ts.map +1 -0
  127. package/dist/data/data-asset.js +122 -0
  128. package/dist/data/data-check-core.d.ts +110 -0
  129. package/dist/data/data-check-core.d.ts.map +1 -0
  130. package/dist/data/data-check-core.js +178 -0
  131. package/dist/data/data-ref.d.ts +79 -0
  132. package/dist/data/data-ref.d.ts.map +1 -0
  133. package/dist/data/data-ref.js +130 -0
  134. package/dist/data/vite-plugin-data.d.ts +79 -0
  135. package/dist/data/vite-plugin-data.d.ts.map +1 -0
  136. package/dist/data/vite-plugin-data.js +217 -0
  137. package/dist/dev/console-bridge.d.ts +22 -0
  138. package/dist/dev/console-bridge.d.ts.map +1 -0
  139. package/dist/dev/console-bridge.js +71 -0
  140. package/dist/dev/debug-draw.d.ts +24 -0
  141. package/dist/dev/debug-draw.d.ts.map +1 -0
  142. package/dist/dev/debug-draw.js +73 -0
  143. package/dist/dev/logger.d.ts +30 -0
  144. package/dist/dev/logger.d.ts.map +1 -0
  145. package/dist/dev/logger.js +90 -0
  146. package/dist/ecs/component-manager.d.ts +189 -0
  147. package/dist/ecs/component-manager.d.ts.map +1 -0
  148. package/dist/ecs/component-manager.js +676 -0
  149. package/dist/ecs/game-component.d.ts +97 -0
  150. package/dist/ecs/game-component.d.ts.map +1 -0
  151. package/dist/ecs/game-component.js +80 -0
  152. package/dist/ecs/hmr-swap-report.d.ts +53 -0
  153. package/dist/ecs/hmr-swap-report.d.ts.map +1 -0
  154. package/dist/ecs/hmr-swap-report.js +55 -0
  155. package/dist/index.d.ts +37 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +30 -0
  158. package/dist/input/input-manager.d.ts +596 -0
  159. package/dist/input/input-manager.d.ts.map +1 -0
  160. package/dist/input/input-manager.js +1564 -0
  161. package/dist/input/input-types.d.ts +171 -0
  162. package/dist/input/input-types.d.ts.map +1 -0
  163. package/dist/input/input-types.js +1 -0
  164. package/dist/input/prompt-labels.d.ts +21 -0
  165. package/dist/input/prompt-labels.d.ts.map +1 -0
  166. package/dist/input/prompt-labels.js +121 -0
  167. package/dist/input/rebind-controller.d.ts +59 -0
  168. package/dist/input/rebind-controller.d.ts.map +1 -0
  169. package/dist/input/rebind-controller.js +85 -0
  170. package/dist/input/schema.d.ts +16 -0
  171. package/dist/input/schema.d.ts.map +1 -0
  172. package/dist/input/schema.js +228 -0
  173. package/dist/loader.d.ts +43 -0
  174. package/dist/loader.d.ts.map +1 -0
  175. package/dist/loader.js +59 -0
  176. package/dist/manifest/index.d.ts +5 -0
  177. package/dist/manifest/index.d.ts.map +1 -0
  178. package/dist/manifest/index.js +8 -0
  179. package/dist/manifest/load-file.d.ts +4 -0
  180. package/dist/manifest/load-file.d.ts.map +1 -0
  181. package/dist/manifest/load-file.js +14 -0
  182. package/dist/manifest/load.d.ts +117 -0
  183. package/dist/manifest/load.d.ts.map +1 -0
  184. package/dist/manifest/load.js +255 -0
  185. package/dist/manifest/schema.d.ts +173 -0
  186. package/dist/manifest/schema.d.ts.map +1 -0
  187. package/dist/manifest/schema.js +302 -0
  188. package/dist/physics/collision-system.d.ts +41 -0
  189. package/dist/physics/collision-system.d.ts.map +1 -0
  190. package/dist/physics/collision-system.js +64 -0
  191. package/dist/physics/physics-registry.d.ts +45 -0
  192. package/dist/physics/physics-registry.d.ts.map +1 -0
  193. package/dist/physics/physics-registry.js +65 -0
  194. package/dist/physics/transform-writer.d.ts +16 -0
  195. package/dist/physics/transform-writer.d.ts.map +1 -0
  196. package/dist/physics/transform-writer.js +38 -0
  197. package/dist/physics/trigger-dispatch.d.ts +24 -0
  198. package/dist/physics/trigger-dispatch.d.ts.map +1 -0
  199. package/dist/physics/trigger-dispatch.js +80 -0
  200. package/dist/react/game-state.d.ts +105 -0
  201. package/dist/react/game-state.d.ts.map +1 -0
  202. package/dist/react/game-state.js +144 -0
  203. package/dist/react/use-data.d.ts +60 -0
  204. package/dist/react/use-data.d.ts.map +1 -0
  205. package/dist/react/use-data.js +96 -0
  206. package/dist/react/use-selection.d.ts +75 -0
  207. package/dist/react/use-selection.d.ts.map +1 -0
  208. package/dist/react/use-selection.js +90 -0
  209. package/dist/render/auto-batcher.d.ts +34 -0
  210. package/dist/render/auto-batcher.d.ts.map +1 -0
  211. package/dist/render/auto-batcher.js +140 -0
  212. package/dist/render/render-batch-system.d.ts +33 -0
  213. package/dist/render/render-batch-system.d.ts.map +1 -0
  214. package/dist/render/render-batch-system.js +230 -0
  215. package/dist/render/render-features.d.ts +47 -0
  216. package/dist/render/render-features.d.ts.map +1 -0
  217. package/dist/render/render-features.js +105 -0
  218. package/dist/render/render-settings.d.ts +36 -0
  219. package/dist/render/render-settings.d.ts.map +1 -0
  220. package/dist/render/render-settings.js +51 -0
  221. package/dist/runtime/create-runtime.d.ts +280 -0
  222. package/dist/runtime/create-runtime.d.ts.map +1 -0
  223. package/dist/runtime/create-runtime.js +795 -0
  224. package/dist/runtime/frame-selector-cache.d.ts +41 -0
  225. package/dist/runtime/frame-selector-cache.d.ts.map +1 -0
  226. package/dist/runtime/frame-selector-cache.js +65 -0
  227. package/dist/runtime/game.d.ts +433 -0
  228. package/dist/runtime/game.d.ts.map +1 -0
  229. package/dist/runtime/game.js +569 -0
  230. package/dist/runtime/input-router.d.ts +78 -0
  231. package/dist/runtime/input-router.d.ts.map +1 -0
  232. package/dist/runtime/input-router.js +171 -0
  233. package/dist/runtime/mount-game.d.ts +129 -0
  234. package/dist/runtime/mount-game.d.ts.map +1 -0
  235. package/dist/runtime/mount-game.js +152 -0
  236. package/dist/runtime/mount-manifest.d.ts +119 -0
  237. package/dist/runtime/mount-manifest.d.ts.map +1 -0
  238. package/dist/runtime/mount-manifest.js +204 -0
  239. package/dist/runtime/render-audio-control.d.ts +110 -0
  240. package/dist/runtime/render-audio-control.d.ts.map +1 -0
  241. package/dist/runtime/render-audio-control.js +111 -0
  242. package/dist/runtime/render-control.d.ts +291 -0
  243. package/dist/runtime/render-control.d.ts.map +1 -0
  244. package/dist/runtime/render-control.js +264 -0
  245. package/dist/runtime/render-seed.d.ts +57 -0
  246. package/dist/runtime/render-seed.d.ts.map +1 -0
  247. package/dist/runtime/render-seed.js +74 -0
  248. package/dist/runtime/scene-ui-bridge.d.ts +69 -0
  249. package/dist/runtime/scene-ui-bridge.d.ts.map +1 -0
  250. package/dist/runtime/scene-ui-bridge.js +23 -0
  251. package/dist/runtime/scene-ui-data.d.ts +13 -0
  252. package/dist/runtime/scene-ui-data.d.ts.map +1 -0
  253. package/dist/runtime/scene-ui-data.js +92 -0
  254. package/dist/runtime/state-bridge.d.ts +47 -0
  255. package/dist/runtime/state-bridge.d.ts.map +1 -0
  256. package/dist/runtime/state-bridge.js +53 -0
  257. package/dist/runtime/types.d.ts +179 -0
  258. package/dist/runtime/types.d.ts.map +1 -0
  259. package/dist/runtime/types.js +1 -0
  260. package/dist/scene/asset-loaders.d.ts +79 -0
  261. package/dist/scene/asset-loaders.d.ts.map +1 -0
  262. package/dist/scene/asset-loaders.js +141 -0
  263. package/dist/scene/asset-paths.d.ts +11 -0
  264. package/dist/scene/asset-paths.d.ts.map +1 -0
  265. package/dist/scene/asset-paths.js +108 -0
  266. package/dist/scene/asset-ref-check.d.ts +88 -0
  267. package/dist/scene/asset-ref-check.d.ts.map +1 -0
  268. package/dist/scene/asset-ref-check.js +214 -0
  269. package/dist/scene/asset-registry.d.ts +30 -0
  270. package/dist/scene/asset-registry.d.ts.map +1 -0
  271. package/dist/scene/asset-registry.js +50 -0
  272. package/dist/scene/collider-dimensions.d.ts +63 -0
  273. package/dist/scene/collider-dimensions.d.ts.map +1 -0
  274. package/dist/scene/collider-dimensions.js +98 -0
  275. package/dist/scene/component-registry.d.ts +17 -0
  276. package/dist/scene/component-registry.d.ts.map +1 -0
  277. package/dist/scene/component-registry.js +28 -0
  278. package/dist/scene/defaults.d.ts +252 -0
  279. package/dist/scene/defaults.d.ts.map +1 -0
  280. package/dist/scene/defaults.js +151 -0
  281. package/dist/scene/geometries/index.d.ts +8 -0
  282. package/dist/scene/geometries/index.d.ts.map +1 -0
  283. package/dist/scene/geometries/index.js +7 -0
  284. package/dist/scene/geometries/terrain.d.ts +2 -0
  285. package/dist/scene/geometries/terrain.d.ts.map +1 -0
  286. package/dist/scene/geometries/terrain.js +37 -0
  287. package/dist/scene/geometry-registry.d.ts +24 -0
  288. package/dist/scene/geometry-registry.d.ts.map +1 -0
  289. package/dist/scene/geometry-registry.js +20 -0
  290. package/dist/scene/instance-registry.d.ts +36 -0
  291. package/dist/scene/instance-registry.d.ts.map +1 -0
  292. package/dist/scene/instance-registry.js +55 -0
  293. package/dist/scene/instancers/grid.d.ts +2 -0
  294. package/dist/scene/instancers/grid.d.ts.map +1 -0
  295. package/dist/scene/instancers/grid.js +36 -0
  296. package/dist/scene/instancers/index.d.ts +8 -0
  297. package/dist/scene/instancers/index.d.ts.map +1 -0
  298. package/dist/scene/instancers/index.js +7 -0
  299. package/dist/scene/light-camera-factory.d.ts +23 -0
  300. package/dist/scene/light-camera-factory.d.ts.map +1 -0
  301. package/dist/scene/light-camera-factory.js +68 -0
  302. package/dist/scene/material-factory.d.ts +17 -0
  303. package/dist/scene/material-factory.d.ts.map +1 -0
  304. package/dist/scene/material-factory.js +197 -0
  305. package/dist/scene/material-registry.d.ts +40 -0
  306. package/dist/scene/material-registry.d.ts.map +1 -0
  307. package/dist/scene/material-registry.js +44 -0
  308. package/dist/scene/materials/index.d.ts +8 -0
  309. package/dist/scene/materials/index.d.ts.map +1 -0
  310. package/dist/scene/materials/index.js +7 -0
  311. package/dist/scene/materials/water.d.ts +2 -0
  312. package/dist/scene/materials/water.d.ts.map +1 -0
  313. package/dist/scene/materials/water.js +54 -0
  314. package/dist/scene/parse.d.ts +23 -0
  315. package/dist/scene/parse.d.ts.map +1 -0
  316. package/dist/scene/parse.js +179 -0
  317. package/dist/scene/particles-factory.d.ts +33 -0
  318. package/dist/scene/particles-factory.d.ts.map +1 -0
  319. package/dist/scene/particles-factory.js +282 -0
  320. package/dist/scene/scene-apply.d.ts +109 -0
  321. package/dist/scene/scene-apply.d.ts.map +1 -0
  322. package/dist/scene/scene-apply.js +326 -0
  323. package/dist/scene/scene-diff-schema.d.ts +285 -0
  324. package/dist/scene/scene-diff-schema.d.ts.map +1 -0
  325. package/dist/scene/scene-diff-schema.js +92 -0
  326. package/dist/scene/scene-diff-types.d.ts +40 -0
  327. package/dist/scene/scene-diff-types.d.ts.map +1 -0
  328. package/dist/scene/scene-diff-types.js +14 -0
  329. package/dist/scene/scene-loader.d.ts +177 -0
  330. package/dist/scene/scene-loader.d.ts.map +1 -0
  331. package/dist/scene/scene-loader.js +1213 -0
  332. package/dist/scene/scene-query.d.ts +11 -0
  333. package/dist/scene/scene-query.d.ts.map +1 -0
  334. package/dist/scene/scene-query.js +63 -0
  335. package/dist/scene/scene-types.d.ts +10 -0
  336. package/dist/scene/scene-types.d.ts.map +1 -0
  337. package/dist/scene/scene-types.js +8 -0
  338. package/dist/scene/scene-version.d.ts +26 -0
  339. package/dist/scene/scene-version.d.ts.map +1 -0
  340. package/dist/scene/scene-version.js +34 -0
  341. package/dist/scene/schema/animation.d.ts +8 -0
  342. package/dist/scene/schema/animation.d.ts.map +1 -0
  343. package/dist/scene/schema/animation.js +42 -0
  344. package/dist/scene/schema/audio.d.ts +13 -0
  345. package/dist/scene/schema/audio.d.ts.map +1 -0
  346. package/dist/scene/schema/audio.js +22 -0
  347. package/dist/scene/schema/camera.d.ts +28 -0
  348. package/dist/scene/schema/camera.d.ts.map +1 -0
  349. package/dist/scene/schema/camera.js +17 -0
  350. package/dist/scene/schema/collider.d.ts +40 -0
  351. package/dist/scene/schema/collider.d.ts.map +1 -0
  352. package/dist/scene/schema/collider.js +55 -0
  353. package/dist/scene/schema/entity-ref.d.ts +31 -0
  354. package/dist/scene/schema/entity-ref.d.ts.map +1 -0
  355. package/dist/scene/schema/entity-ref.js +61 -0
  356. package/dist/scene/schema/entity.d.ts +1036 -0
  357. package/dist/scene/schema/entity.d.ts.map +1 -0
  358. package/dist/scene/schema/entity.js +153 -0
  359. package/dist/scene/schema/environment.d.ts +425 -0
  360. package/dist/scene/schema/environment.d.ts.map +1 -0
  361. package/dist/scene/schema/environment.js +357 -0
  362. package/dist/scene/schema/index.d.ts +39 -0
  363. package/dist/scene/schema/index.d.ts.map +1 -0
  364. package/dist/scene/schema/index.js +20 -0
  365. package/dist/scene/schema/instances.d.ts +14 -0
  366. package/dist/scene/schema/instances.d.ts.map +1 -0
  367. package/dist/scene/schema/instances.js +28 -0
  368. package/dist/scene/schema/joint.d.ts +23 -0
  369. package/dist/scene/schema/joint.d.ts.map +1 -0
  370. package/dist/scene/schema/joint.js +23 -0
  371. package/dist/scene/schema/light.d.ts +38 -0
  372. package/dist/scene/schema/light.d.ts.map +1 -0
  373. package/dist/scene/schema/light.js +26 -0
  374. package/dist/scene/schema/material.d.ts +199 -0
  375. package/dist/scene/schema/material.d.ts.map +1 -0
  376. package/dist/scene/schema/material.js +99 -0
  377. package/dist/scene/schema/mesh.d.ts +48 -0
  378. package/dist/scene/schema/mesh.d.ts.map +1 -0
  379. package/dist/scene/schema/mesh.js +82 -0
  380. package/dist/scene/schema/particles.d.ts +2178 -0
  381. package/dist/scene/schema/particles.d.ts.map +1 -0
  382. package/dist/scene/schema/particles.js +321 -0
  383. package/dist/scene/schema/physics.d.ts +56 -0
  384. package/dist/scene/schema/physics.d.ts.map +1 -0
  385. package/dist/scene/schema/physics.js +37 -0
  386. package/dist/scene/schema/scene-file.d.ts +304 -0
  387. package/dist/scene/schema/scene-file.d.ts.map +1 -0
  388. package/dist/scene/schema/scene-file.js +262 -0
  389. package/dist/scene/schema/shadow.d.ts +28 -0
  390. package/dist/scene/schema/shadow.d.ts.map +1 -0
  391. package/dist/scene/schema/shadow.js +20 -0
  392. package/dist/scene/schema/spline.d.ts +25 -0
  393. package/dist/scene/schema/spline.d.ts.map +1 -0
  394. package/dist/scene/schema/spline.js +15 -0
  395. package/dist/scene/schema/tuples.d.ts +12 -0
  396. package/dist/scene/schema/tuples.d.ts.map +1 -0
  397. package/dist/scene/schema/tuples.js +14 -0
  398. package/dist/scene/schema/ui.d.ts +880 -0
  399. package/dist/scene/schema/ui.d.ts.map +1 -0
  400. package/dist/scene/schema/ui.js +506 -0
  401. package/dist/scene/user-data.d.ts +171 -0
  402. package/dist/scene/user-data.d.ts.map +1 -0
  403. package/dist/scene/user-data.js +137 -0
  404. package/dist/setup/setup-audio.d.ts +20 -0
  405. package/dist/setup/setup-audio.d.ts.map +1 -0
  406. package/dist/setup/setup-audio.js +41 -0
  407. package/dist/setup/setup-particles.d.ts +17 -0
  408. package/dist/setup/setup-particles.d.ts.map +1 -0
  409. package/dist/setup/setup-particles.js +16 -0
  410. package/dist/setup/setup-physics.d.ts +25 -0
  411. package/dist/setup/setup-physics.d.ts.map +1 -0
  412. package/dist/setup/setup-physics.js +51 -0
  413. package/dist/setup/setup-renderer.d.ts +69 -0
  414. package/dist/setup/setup-renderer.d.ts.map +1 -0
  415. package/dist/setup/setup-renderer.js +363 -0
  416. package/dist/tools/define-tool.d.ts +105 -0
  417. package/dist/tools/define-tool.d.ts.map +1 -0
  418. package/dist/tools/define-tool.js +82 -0
  419. package/dist/world2d/authoring-2d.d.ts +76 -0
  420. package/dist/world2d/authoring-2d.d.ts.map +1 -0
  421. package/dist/world2d/authoring-2d.js +176 -0
  422. package/dist/world2d/capture-to-scene2d.d.ts +5 -0
  423. package/dist/world2d/capture-to-scene2d.d.ts.map +1 -0
  424. package/dist/world2d/capture-to-scene2d.js +49 -0
  425. package/dist/world2d/collision-2d.d.ts +52 -0
  426. package/dist/world2d/collision-2d.d.ts.map +1 -0
  427. package/dist/world2d/collision-2d.js +70 -0
  428. package/dist/world2d/components-2d.d.ts +47 -0
  429. package/dist/world2d/components-2d.d.ts.map +1 -0
  430. package/dist/world2d/components-2d.js +77 -0
  431. package/dist/world2d/index.d.ts +34 -0
  432. package/dist/world2d/index.d.ts.map +1 -0
  433. package/dist/world2d/index.js +32 -0
  434. package/dist/world2d/ingest-iframe-2d.d.ts +82 -0
  435. package/dist/world2d/ingest-iframe-2d.d.ts.map +1 -0
  436. package/dist/world2d/ingest-iframe-2d.js +166 -0
  437. package/dist/world2d/ingest2d.d.ts +97 -0
  438. package/dist/world2d/ingest2d.d.ts.map +1 -0
  439. package/dist/world2d/ingest2d.js +40 -0
  440. package/dist/world2d/physics2d-registry.d.ts +24 -0
  441. package/dist/world2d/physics2d-registry.d.ts.map +1 -0
  442. package/dist/world2d/physics2d-registry.js +38 -0
  443. package/dist/world2d/pixi-game-adapter.d.ts +107 -0
  444. package/dist/world2d/pixi-game-adapter.d.ts.map +1 -0
  445. package/dist/world2d/pixi-game-adapter.js +211 -0
  446. package/dist/world2d/pixi-surface.d.ts +39 -0
  447. package/dist/world2d/pixi-surface.d.ts.map +1 -0
  448. package/dist/world2d/pixi-surface.js +47 -0
  449. package/dist/world2d/scene-capture-2d.d.ts +43 -0
  450. package/dist/world2d/scene-capture-2d.d.ts.map +1 -0
  451. package/dist/world2d/scene-capture-2d.js +92 -0
  452. package/dist/world2d/scene2d-loader.d.ts +27 -0
  453. package/dist/world2d/scene2d-loader.d.ts.map +1 -0
  454. package/dist/world2d/scene2d-loader.js +274 -0
  455. package/dist/world2d/schema/entity2d.d.ts +102 -0
  456. package/dist/world2d/schema/entity2d.d.ts.map +1 -0
  457. package/dist/world2d/schema/entity2d.js +119 -0
  458. package/dist/world2d/schema/physics2d.d.ts +46 -0
  459. package/dist/world2d/schema/physics2d.d.ts.map +1 -0
  460. package/dist/world2d/schema/physics2d.js +45 -0
  461. package/dist/world2d/schema/sprite.d.ts +42 -0
  462. package/dist/world2d/schema/sprite.d.ts.map +1 -0
  463. package/dist/world2d/schema/sprite.js +63 -0
  464. package/dist/world2d/schema/tilemap.d.ts +15 -0
  465. package/dist/world2d/schema/tilemap.d.ts.map +1 -0
  466. package/dist/world2d/schema/tilemap.js +19 -0
  467. package/dist/world2d/schema/tuples2d.d.ts +20 -0
  468. package/dist/world2d/schema/tuples2d.d.ts.map +1 -0
  469. package/dist/world2d/schema/tuples2d.js +19 -0
  470. package/dist/world2d/system-adapters-2d.d.ts +16 -0
  471. package/dist/world2d/system-adapters-2d.d.ts.map +1 -0
  472. package/dist/world2d/system-adapters-2d.js +37 -0
  473. package/dist/world2d/transform-writer-2d.d.ts +16 -0
  474. package/dist/world2d/transform-writer-2d.d.ts.map +1 -0
  475. package/dist/world2d/transform-writer-2d.js +22 -0
  476. package/dist/world2d/types.d.ts +53 -0
  477. package/dist/world2d/types.d.ts.map +1 -0
  478. package/dist/world2d/types.js +1 -0
  479. package/package.json +18 -4
  480. package/src/adapter/authoring.ts +10 -0
  481. package/src/adapter/first-party-systems.ts +23 -34
  482. package/src/adapter/index.ts +1 -3
  483. package/src/adapter/system-adapter.ts +15 -22
  484. package/src/adapter/vgai-scene-game-adapter.ts +5 -14
  485. package/src/animation/anim-graph-types.ts +12 -43
  486. package/src/animation/animation-clock.ts +479 -0
  487. package/src/animation/camera-ownership.ts +467 -0
  488. package/src/animation/cinematic-cues.ts +451 -0
  489. package/src/animation/clip-map.ts +41 -0
  490. package/src/animation/gsap-registration.ts +184 -0
  491. package/src/animation/theatre-clock-binding.ts +111 -0
  492. package/src/animation/theatre-director.ts +347 -0
  493. package/src/animation/theatre-object-binding.ts +661 -0
  494. package/src/animation/xstate-animation-binding.ts +398 -0
  495. package/src/animation/xstate-animation-meta.ts +282 -0
  496. package/src/audio/index.ts +39 -7
  497. package/src/audio/tone-clock-binding.ts +98 -0
  498. package/src/audio/tone-context.ts +129 -0
  499. package/src/audio/tone-offline-render.ts +167 -0
  500. package/src/audio/wav-encode.ts +119 -0
  501. package/src/core/game-loop.ts +17 -0
  502. package/src/core/types.ts +13 -0
  503. package/src/data/data-asset.ts +167 -0
  504. package/src/data/data-check-core.ts +242 -0
  505. package/src/data/data-ref.ts +145 -0
  506. package/src/data/vite-plugin-data.ts +290 -0
  507. package/src/index.ts +48 -0
  508. package/src/input/input-manager.ts +1206 -32
  509. package/src/input/input-types.ts +155 -3
  510. package/src/input/prompt-labels.ts +122 -0
  511. package/src/input/rebind-controller.ts +105 -0
  512. package/src/input/schema.ts +206 -52
  513. package/src/react/use-data.ts +114 -0
  514. package/src/react/use-selection.tsx +135 -0
  515. package/src/runtime/create-runtime.ts +30 -9
  516. package/src/runtime/render-audio-control.ts +168 -0
  517. package/src/runtime/render-control.ts +522 -0
  518. package/src/runtime/render-seed.ts +79 -0
  519. package/src/runtime/types.ts +0 -5
  520. package/src/scene/asset-loaders.ts +10 -36
  521. package/src/scene/asset-paths.ts +0 -2
  522. package/src/scene/asset-ref-check.ts +248 -0
  523. package/src/scene/parse.ts +133 -0
  524. package/src/scene/scene-loader.ts +81 -96
  525. package/src/scene/schema/animation.ts +30 -79
  526. package/src/scene/schema/entity.ts +20 -0
  527. package/src/scene/schema/index.ts +2 -12
  528. package/src/scene/user-data.ts +8 -9
  529. package/src/setup/setup-renderer.ts +9 -2
  530. package/src/tools/define-tool.ts +152 -0
  531. package/src/animation/anim-graph.ts +0 -406
  532. package/src/animation/anim-system.ts +0 -28
  533. package/src/animation/property-track.ts +0 -178
  534. package/src/animation/schema.ts +0 -204
  535. package/src/audio/ambient.ts +0 -300
  536. package/src/audio/impacts.ts +0 -212
  537. package/src/audio/movement.ts +0 -140
  538. package/src/audio/musical.ts +0 -200
  539. package/src/audio/ui-sounds.ts +0 -171
  540. package/src/audio/vehicle.ts +0 -235
  541. package/src/audio/weapons.ts +0 -152
@@ -1,6 +1,24 @@
1
1
  import { resolveUrl } from '../loader';
2
2
  import { SceneParseError } from '../scene/parse';
3
- import type { InputBinding, InputMapFile } from './input-types';
3
+ import type {
4
+ ActionValueOf,
5
+ ActionValueSource,
6
+ ActionValueType,
7
+ BindingConflict,
8
+ BindingPrompt,
9
+ InputBinding,
10
+ InputMapFile,
11
+ PromptDevice,
12
+ Vector2,
13
+ } from './input-types';
14
+ import {
15
+ bindingDeviceFamily,
16
+ gamepadAxisLabel,
17
+ gamepadAxisPairLabel,
18
+ keyLabel,
19
+ mouseButtonLabel,
20
+ STANDARD_GAMEPAD_BUTTON_LABELS,
21
+ } from './prompt-labels';
4
22
  import { InputMapFileSchema } from './schema';
5
23
 
6
24
  /**
@@ -31,9 +49,182 @@ function isTextEntryFocused(): boolean {
31
49
  * if (input.isPressed('jump')) { ... }
32
50
  * if (input.isJustPressed('attack')) { ... }
33
51
  * input.endFrame(); // call at end of frame
52
+ *
53
+ * F1 (spec §12 "Define Typed Action Values") — typed action values:
54
+ *
55
+ * Every action DECLARES a value shape (`InputAction.valueType`, defaulted to
56
+ * `'digital'` — see `input-types.ts`'s `ActionValueType`). Each shape has its
57
+ * own typed getter, none of which return `any`:
58
+ *
59
+ * - `'digital'` — `isPressed`/`isJustPressed`/`isJustReleased` -> `boolean`
60
+ * - `'scalar'` — `getScalar` -> `number`
61
+ * - `'vector2'` — `getVector2` -> `Vector2` ({x,y})
62
+ * - `'pointerDelta'` — `getPointerDelta` -> `Vector2`
63
+ * - `'pointerPosition'` — `getPointerPosition` -> `Vector2`
64
+ *
65
+ * Reading an action through the WRONG getter (e.g. `getScalar` on a
66
+ * `'digital'` action) throws — that's the type-safety enforcement point,
67
+ * since actions are declared in data (JSON), not TS types, so there's no
68
+ * compiler backstop otherwise. `readAction(name, expectedType)` is a generic
69
+ * entry point over the same getters, typed via `ActionValueOf<T>`.
70
+ *
71
+ * Each typed getter has a `*Source` sibling (e.g. `getScalarSource`) that
72
+ * returns `ActionValueSource | null` — which binding (and, for gamepad
73
+ * bindings, which connected gamepad, or for injected test bindings, which
74
+ * named source) produced the current value. That's the "device/source
75
+ * metadata" AC.
76
+ *
77
+ * Deadzone + normalization:
78
+ * - scalar: below `deadzone` magnitude -> 0; at/above it, rescaled from 0
79
+ * (at the deadzone edge) to ±1 (at |raw| = 1), sign preserved
80
+ * (`rescaleScalar`).
81
+ * - vector2: below `deadzone` magnitude -> {0,0}; at/above it, direction is
82
+ * preserved and magnitude is rescaled the same way, then the result is
83
+ * clamped to the unit circle — magnitude never exceeds 1 even for a raw
84
+ * diagonal input like {1,1} (`rescaleVector2`).
85
+ *
86
+ * Simultaneous-binding combine rules (documented here, spec §12 F1
87
+ * "simultaneous bindings combine predictably"):
88
+ * - digital: OR — any one satisfied binding makes the action pressed
89
+ * (pre-existing behavior, unchanged).
90
+ * - scalar: max-magnitude across contributing bindings' (deadzone-applied)
91
+ * values, sign preserved; ties keep the first-listed binding
92
+ * (deterministic given binding array order).
93
+ * - vector2: sum contributing (deadzone-applied) {x,y} values component-
94
+ * wise, then clamp the sum to the unit circle (never renormalized UP —
95
+ * only ever scaled down if the sum exceeds magnitude 1).
96
+ * - pointerDelta: sum contributing deltas (matches the existing
97
+ * mouseDeltaX/Y + lookStick accumulation precedent below).
98
+ * - pointerPosition: last-write-wins — the contributing source most
99
+ * recently updated (by injection call order) wins; position isn't
100
+ * additive so summing would be meaningless.
101
+ *
102
+ * Synthetic input injection (test hook, also the shape of F2's "injected
103
+ * test input" backend): `injectAxis`/`injectVector2`/`injectPointerDelta`/
104
+ * `injectPointerPosition` push a raw value into a named source, read back by
105
+ * binding an action to `{ type: 'test_axis' | 'test_vector2' |
106
+ * 'test_pointer_delta' | 'test_pointer_position', sourceId }` — the exact
107
+ * same aggregation path (deadzone/normalize/combine) real device bindings
108
+ * use.
109
+ *
110
+ * F2 (spec §12 "Complete Device Backends") completes the real-device side of
111
+ * that same typed-value path:
112
+ *
113
+ * - `mouse_move` — un-rejected (schema.ts). Feeds `getPointerDelta` (raw
114
+ * accumulated `mouseDeltaX/Y`, no deadzone) and `getVector2` (the same
115
+ * delta, deadzone-rescaled + unit-circle-clamped).
116
+ * - `gamepad_axis_pair` — un-rejected (schema.ts). Feeds `getVector2` with
117
+ * the coupled `(xAxis, yAxis)` reading, deadzone-rescaled like a real
118
+ * stick (default deadzone 0.15, matching `gamepad_axis`).
119
+ * - `touch_button`/`touch_stick` — new binding kinds for touch/virtual
120
+ * controls. `setTouchButton(sourceId, pressed)` feeds a digital action
121
+ * (edge-tracked like `mouse_button`); `setTouchStick(sourceId, value)`
122
+ * feeds a vector2 action (deadzone-rescaled like a gamepad stick). These
123
+ * are the injectable entry points a real on-screen touch UI's own
124
+ * touchstart/touchmove/touchend handlers would call — that DOM wiring is
125
+ * a thin adapter at the edge (browser-verified separately, e.g. F4/e2e);
126
+ * the mapping logic here is unit-tested headlessly with no DOM.
127
+ * - Focus gating — `blur`/`focus` on `window` (guarded, real DOM only) now
128
+ * drop input the same way `setEnabled(false)` does (flushing held
129
+ * key/mouse/touch-button state) without touching the `enabled` flag the
130
+ * editor drives — see `focused`/`inputActive` below. This is what stops
131
+ * a stuck key/button when the user alt-tabs away mid-press.
132
+ * - Pointer lock — `pointerlockchange` on `document` (guarded) tracks
133
+ * `isPointerLocked()`; losing lock flushes mouse-button state (a click
134
+ * held at the moment lock exits must not stick).
135
+ * - Gamepad device identity — `getGamepadInfo(index)` exposes `{index, id,
136
+ * mapping, connected}`; `ActionValueSource.gamepadId` (in addition to the
137
+ * pre-existing `gamepadIndex`) is populated for every
138
+ * gamepad_button/gamepad_axis/gamepad_axis_pair source.
139
+ * - Disconnection/reconnection needs no new tracking: `poll()` already
140
+ * re-reads `navigator.getGamepads()` every frame and every read (digital
141
+ * edges, scalar/vector2 contributions) already skips a `null` gamepad
142
+ * entry, so a disconnected gamepad's bound actions go neutral (and a
143
+ * previously-held button correctly fires `isJustReleased`) with no
144
+ * special-cased code — see the state-transition tests.
145
+ *
146
+ * F3 (spec §12 "Add Rebinding and Prompts") completes the input system with
147
+ * rebinding, structural conflict detection, and device-prompt resolution:
148
+ *
149
+ * - Listing/mutating: `getBindings`/`setBindings`/`addBinding`/
150
+ * `removeBinding`/`replaceBinding` — the same `this.actions` map F1/F2
151
+ * already read, now with a public read + mutation surface (there was
152
+ * previously none — every existing call site either loaded a map once or
153
+ * read through the typed getters).
154
+ * - Reset-to-defaults: `registerAction`/`loadMap`/`loadMapObject` snapshot
155
+ * each action's bindings into `defaultBindings` (a deep clone) at
156
+ * registration/load time; `resetBindings`/`resetAllBindings` restore from
157
+ * that snapshot. Later rebinding calls do NOT touch the snapshot, so
158
+ * "reset" always means "back to what was authored/loaded", not "back to
159
+ * the last reset".
160
+ * - Persist: `toInputMapFile()` serializes the live action set back into
161
+ * the exact `.inputmap.json` shape (`InputMapFile`) `loadMap` reads;
162
+ * `loadMapObject(data)` is `loadMap`'s synchronous, no-`fetch` sibling —
163
+ * both funnel through the same `InputMapFileSchema`-validated
164
+ * `applyParsedMap`. This is the round-trip seam: serialize, write/hand to
165
+ * something else, reload, and rebound actions survive. It complements —
166
+ * doesn't replace — the SDK's `project.inputMap.read/validate/update`
167
+ * operations (B2, `packages/vgai-sdk/src/project/input-map-operations.ts`):
168
+ * those are FILE-level (a CLI/agent editing the `.inputmap.json` on disk,
169
+ * no running game involved); this is RUNTIME-level (a live game rebinding
170
+ * while playing). Both validate through the identical
171
+ * `InputMapFileSchema`, so a file B2 wrote loads here unchanged, and a
172
+ * map this class serializes is a valid document for B2 to read back.
173
+ * - Conflicts: `findConflicts(proposed, excludeActionName?)` scans every
174
+ * OTHER registered action's bindings for one that's structurally
175
+ * equivalent to `proposed` (see the private `bindingsCollide` — same
176
+ * physical input identity: same key code, same mouse button, the same
177
+ * gamepad button/axis/axis-pair, the same touch `sourceId`, etc.) and
178
+ * returns the (possibly empty) list as plain `BindingConflict` data —
179
+ * never throws. A rebinding UI calls this BEFORE calling
180
+ * `addBinding`/`replaceBinding`/`setBindings` and decides what to do with
181
+ * a non-empty result (block, warn, let the user steal the binding by
182
+ * removing it from the other action first).
183
+ * - Prompts: `getPrompt(actionName, device?, gamepadIndex?)` resolves a
184
+ * `BindingPrompt` (display label + icon name) for whichever of the
185
+ * action's bindings belongs to the requested `PromptDevice` family
186
+ * (keyboard/mouse/gamepad/touch — `bindingDeviceFamily` in
187
+ * `prompt-labels.ts`), using `getGamepadInfo` to prefer the standard
188
+ * mapping's canonical glyph names (`'A'`, `'LB'`, ...) for gamepad
189
+ * buttons and falling back to a raw index label (or a "disconnected"
190
+ * label) when the pad isn't in standard mapping or isn't connected.
191
+ * `device` is optional (F4 below) — omit it to resolve against
192
+ * `getLastActiveDevice()` instead.
193
+ *
194
+ * F4 (spec §12 "Schema and Examples") closes F3's one open gap — a
195
+ * current-ACTIVE-device tracker, so a prompt UI can switch its displayed
196
+ * label as the player switches controllers instead of the caller having to
197
+ * track that itself:
198
+ *
199
+ * - `getLastActiveDevice()` returns the `PromptDevice` family of whichever
200
+ * input arrived most recently — keyboard on `keydown`, mouse on
201
+ * `mousedown`/a real `mousemove`, gamepad on a button press or a stick
202
+ * pushed meaningfully off-center (checked in `poll()`), touch on
203
+ * `setTouchButton(_, true)` or `setTouchStick` pushed meaningfully
204
+ * off-center. `null` before any input has arrived.
205
+ * - `getPrompt(actionName)` (device omitted) resolves against it directly,
206
+ * falling back to `'keyboard'` before any input has arrived — a HUD can
207
+ * call it every frame with no extra bookkeeping and the label switches on
208
+ * its own.
34
209
  */
35
210
  export class InputManager {
36
211
  private actions = new Map<string, InputBinding[]>();
212
+ // F1 — each registered/loaded action's declared value shape (defaults to
213
+ // 'digital' — see registerAction/loadMap). Kept as a sibling map rather than
214
+ // folded into `actions` so the pre-F1 `Map<string, InputBinding[]>` shape
215
+ // (and every existing direct read of it, e.g. in tests) stays unchanged.
216
+ private actionValueTypes = new Map<string, ActionValueType>();
217
+ // F3 — a deep-cloned snapshot of each action's bindings AT REGISTRATION/LOAD
218
+ // TIME (registerAction/loadMap/loadMapObject), restored by
219
+ // resetBindings/resetAllBindings. Later rebinding calls (setBindings/
220
+ // addBinding/removeBinding/replaceBinding) deliberately do not touch this —
221
+ // "reset" always means "back to the originally authored/loaded defaults".
222
+ private defaultBindings = new Map<string, InputBinding[]>();
223
+ // F3 — the `version` field of the last `loadMap`/`loadMapObject`-ed
224
+ // document, carried forward by `toInputMapFile()` so a round-trip
225
+ // (serialize -> reload) preserves it. Defaults to 1 for a manager built
226
+ // purely via `registerAction` (no file was ever loaded).
227
+ private loadedMapVersion = 1;
37
228
  private keysDown = new Set<string>();
38
229
  private keysJustDown = new Set<string>();
39
230
  private keysJustUp = new Set<string>();
@@ -61,6 +252,50 @@ export class InputManager {
61
252
  private gamepadAxesPrev = new Map<string, number>();
62
253
  private lookStickX = 0;
63
254
  private lookStickY = 0;
255
+ // F1 — injected-test-input raw value stores, keyed by caller-chosen
256
+ // `sourceId` (see the class doc comment's "Synthetic input injection"
257
+ // section). Axis/Vector2/pointer-position are LEVEL values (persist across
258
+ // frames until changed, mirroring a real analog stick/gamepad axis —
259
+ // there's no hardware poll to naturally refresh them each frame); pointer
260
+ // delta is a per-frame accumulator (cleared in endFrame(), mirroring
261
+ // mouseDeltaX/Y above).
262
+ private testAxisValues = new Map<string, number>();
263
+ private testVector2Values = new Map<string, Vector2>();
264
+ private testPointerDeltaAccum = new Map<string, Vector2>();
265
+ private testPointerPositionValues = new Map<string, { value: Vector2; seq: number }>();
266
+ // Monotonic counter stamped onto each injectPointerPosition call so the
267
+ // pointerPosition combine rule (last-write-wins) can tell which of several
268
+ // contributing sources was written most recently within/across frames.
269
+ private pointerPositionSeq = 0;
270
+ // F2 — touch/virtual-control state, keyed by caller-chosen `sourceId` (the
271
+ // touch UI's own zone/knob id — see setTouchButton/setTouchStick). Buttons
272
+ // mirror the mouse-button edge-tracking shape (Down/JustDown/JustUp sets);
273
+ // sticks are LEVEL values like a gamepad axis (persist until changed).
274
+ private touchButtonsDown = new Set<string>();
275
+ private touchButtonsJustDown = new Set<string>();
276
+ private touchButtonsJustUp = new Set<string>();
277
+ private touchStickValues = new Map<string, Vector2>();
278
+ // F4 (spec §12 "Schema and Examples" — the F3 prompt-switching gap) — which
279
+ // device family most recently produced REAL input, so a prompt UI can
280
+ // switch its displayed label ('Space' -> 'A' -> a touch control's name) as
281
+ // the player switches controllers, without the caller having to guess or
282
+ // track this itself. Updated at the same points raw input arrives: keydown
283
+ // (`'keyboard'`), mousedown/mousemove (`'mouse'`), a gamepad button press or
284
+ // a stick pushed meaningfully past rest (`'gamepad'`, both detected in
285
+ // `poll()` — a raw resting stick reports small non-zero noise on some
286
+ // pads, so this deliberately checks a coarse magnitude, not "any non-zero
287
+ // axis"), and a touch button press / stick pushed past rest
288
+ // (`'touch'` — `setTouchButton`/`setTouchStick`). `null` until the very
289
+ // first input of any kind arrives. See `getLastActiveDevice`/`getPrompt`.
290
+ private lastActiveDevice: PromptDevice | null = null;
291
+ /** Coarse "is this stick meaningfully off-center" check shared by the
292
+ * gamepad and touch-stick active-device heuristics above — deliberately a
293
+ * higher bar than a binding's own (often much smaller) configured
294
+ * deadzone, since this is "did the player just grab this control", not
295
+ * "does this binding's value count as non-zero". */
296
+ private static isStickActive(v: Vector2): boolean {
297
+ return Math.hypot(v.x, v.y) > 0.3;
298
+ }
64
299
  private disposed = false;
65
300
  /**
66
301
  * When false, all raw input is ignored and held/transient state is cleared.
@@ -71,16 +306,48 @@ export class InputManager {
71
306
  * for its whole lifetime.
72
307
  */
73
308
  private enabled = true;
309
+ /**
310
+ * F2 — real window focus, distinct from `enabled` (which is the editor's
311
+ * explicit play-mode gate). `false` while the tab/window is blurred (real
312
+ * `blur`/`focus` events — see the constructor). Held keyboard/mouse/touch
313
+ * state is flushed on blur so a key/button held at alt-tab time doesn't
314
+ * stick (the browser stops delivering keyup/mouseup while blurred).
315
+ */
316
+ private focused = true;
317
+ /** Both gates a standalone game must pass for input to be live (spec §12
318
+ * F2 "focus gating"): the editor's explicit `enabled` flag AND real
319
+ * window focus. Used everywhere `enabled` alone used to be checked. */
320
+ private get inputActive(): boolean {
321
+ return this.enabled && this.focused;
322
+ }
323
+ /** F2 — real pointer-lock state (`document.pointerLockElement`), tracked via
324
+ * `pointerlockchange` (see the constructor). */
325
+ private pointerLocked = false;
74
326
  /** Element currently wired for click-to-pointer-lock, and its listener. */
75
327
  private pointerLockElement: HTMLElement | null = null;
76
328
  private onPointerLockClick = () => {
77
329
  this.pointerLockElement?.requestPointerLock();
78
330
  };
331
+ private onPointerLockChange = () => {
332
+ const doc = typeof document !== 'undefined' ? document : null;
333
+ const wasLocked = this.pointerLocked;
334
+ this.pointerLocked =
335
+ !!this.pointerLockElement && doc?.pointerLockElement === this.pointerLockElement;
336
+ // Losing lock mid-click must not leave a mouse button stuck down — the
337
+ // browser may not deliver a mouseup when lock exits (e.g. Escape).
338
+ if (wasLocked && !this.pointerLocked) {
339
+ this.mouseButtons.clear();
340
+ this.mouseButtonsJustDown.clear();
341
+ this.mouseButtonsJustUp.clear();
342
+ }
343
+ };
79
344
 
80
345
  private onKeyDown = (e: KeyboardEvent) => {
81
- // Ignore game input while suspended or while the user is typing into a text
82
- // field (e.g. renaming an entity / editing an inspector value in the editor).
83
- if (!this.enabled || isTextEntryFocused()) return;
346
+ // Ignore game input while suspended, unfocused, or while the user is typing
347
+ // into a text field (e.g. renaming an entity / editing an inspector value
348
+ // in the editor).
349
+ if (!this.inputActive || isTextEntryFocused()) return;
350
+ this.lastActiveDevice = 'keyboard';
84
351
  if (!this.keysDown.has(e.code)) {
85
352
  this.keysJustDown.add(e.code);
86
353
  }
@@ -88,35 +355,81 @@ export class InputManager {
88
355
  };
89
356
 
90
357
  private onKeyUp = (e: KeyboardEvent) => {
91
- if (!this.enabled) return;
358
+ if (!this.inputActive) return;
92
359
  this.keysDown.delete(e.code);
93
360
  this.keysJustUp.add(e.code);
94
361
  };
95
362
 
96
363
  private onMouseDown = (e: MouseEvent) => {
97
- if (!this.enabled) return;
364
+ if (!this.inputActive) return;
365
+ this.lastActiveDevice = 'mouse';
98
366
  this.mouseButtons.add(e.button);
99
367
  this.mouseButtonsJustDown.add(e.button);
100
368
  };
101
369
 
102
370
  private onMouseUp = (e: MouseEvent) => {
103
- if (!this.enabled) return;
371
+ if (!this.inputActive) return;
104
372
  this.mouseButtons.delete(e.button);
105
373
  this.mouseButtonsJustUp.add(e.button);
106
374
  };
107
375
 
108
376
  private onMouseMove = (e: MouseEvent) => {
109
- if (!this.enabled) return;
377
+ if (!this.inputActive) return;
378
+ if (e.movementX !== 0 || e.movementY !== 0) this.lastActiveDevice = 'mouse';
110
379
  this.mouseDeltaX += e.movementX;
111
380
  this.mouseDeltaY += e.movementY;
112
381
  };
113
382
 
383
+ /** F2 — real window blur: drop focus and flush held state, mirroring
384
+ * `setEnabled(false)`'s flush (see `flushHeldState`), without touching the
385
+ * editor-driven `enabled` flag. */
386
+ private onWindowBlur = () => {
387
+ this.focused = false;
388
+ this.flushHeldState();
389
+ };
390
+
391
+ /** F2 — real window focus regain. No flush needed (there's nothing held to
392
+ * flush — a fresh keydown/mousedown is required to register pressed
393
+ * again, exactly like re-enabling via `setEnabled(true)`). */
394
+ private onWindowFocus = () => {
395
+ this.focused = true;
396
+ };
397
+
398
+ /** Clear all held/transient keyboard/mouse/touch-button state — shared by
399
+ * `setEnabled(false)` and real blur (`onWindowBlur`), so a key/button
400
+ * physically held when input is suspended doesn't linger. Level values
401
+ * (gamepad axes, touch sticks, injected test axes) are deliberately NOT
402
+ * flushed here — they are gated at READ time instead (see `inputActive`
403
+ * checks in `poll()`/the `collect*Contributions` methods), matching the
404
+ * pre-existing gamepad-axis convention. */
405
+ private flushHeldState(): void {
406
+ this.keysDown.clear();
407
+ this.keysJustDown.clear();
408
+ this.keysJustUp.clear();
409
+ this.mouseButtons.clear();
410
+ this.mouseButtonsJustDown.clear();
411
+ this.mouseButtonsJustUp.clear();
412
+ this.mouseDeltaX = 0;
413
+ this.mouseDeltaY = 0;
414
+ this.touchButtonsDown.clear();
415
+ this.touchButtonsJustDown.clear();
416
+ this.touchButtonsJustUp.clear();
417
+ }
418
+
114
419
  constructor() {
115
420
  window.addEventListener('keydown', this.onKeyDown);
116
421
  window.addEventListener('keyup', this.onKeyUp);
117
422
  window.addEventListener('mousedown', this.onMouseDown);
118
423
  window.addEventListener('mouseup', this.onMouseUp);
119
424
  window.addEventListener('mousemove', this.onMouseMove);
425
+ window.addEventListener('blur', this.onWindowBlur);
426
+ window.addEventListener('focus', this.onWindowFocus);
427
+ // `document` isn't touched by any pre-F2 code path (the headless vitest
428
+ // env stubs `window` only) — guard the same way `isTextEntryFocused` does
429
+ // so tests that don't need pointer-lock coverage need no `document` stub.
430
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
431
+ document.addEventListener('pointerlockchange', this.onPointerLockChange);
432
+ }
120
433
  }
121
434
 
122
435
  /**
@@ -143,14 +456,72 @@ export class InputManager {
143
456
  console.error(message);
144
457
  throw err instanceof SceneParseError ? err : new Error(message);
145
458
  }
459
+ this.applyParsedMap(data);
460
+ }
461
+
462
+ /**
463
+ * F3 — `loadMap`'s synchronous, no-`fetch` sibling: apply an already-in-
464
+ * memory (or freshly-deserialized) input-map DOCUMENT, validated through
465
+ * the identical `InputMapFileSchema` (throws `SceneParseError` naming no
466
+ * file, matching `loadMap`'s error shape minus the URL). This is the other
467
+ * half of the F3 persist round-trip: `toInputMapFile()` serializes,
468
+ * `loadMapObject` re-applies — no network/file I/O required, so it's also
469
+ * how a headless test proves a rebind survives a save/reload cycle.
470
+ */
471
+ loadMapObject(data: unknown): InputMapFile {
472
+ const result = InputMapFileSchema.safeParse(data);
473
+ if (!result.success) throw new SceneParseError(result.error.issues);
474
+ this.applyParsedMap(result.data);
475
+ return result.data;
476
+ }
477
+
478
+ /** Shared apply step for `loadMap`/`loadMapObject`: copy each action's
479
+ * bindings + declared valueType into the live maps, and snapshot the
480
+ * bindings as that action's F3 reset-to-defaults baseline. */
481
+ private applyParsedMap(data: InputMapFile): void {
482
+ this.loadedMapVersion = data.version;
146
483
  for (const [name, action] of Object.entries(data.actions)) {
147
484
  this.actions.set(name, action.bindings);
485
+ // `InputActionSchema.valueType` has a Zod `.default('digital')`, so a
486
+ // successfully-parsed action always carries one even when the source
487
+ // JSON omitted it.
488
+ this.actionValueTypes.set(name, action.valueType ?? 'digital');
489
+ this.defaultBindings.set(name, structuredClone(action.bindings));
148
490
  }
149
491
  }
150
492
 
151
- /** Register an action programmatically */
152
- registerAction(name: string, bindings: InputBinding[]) {
493
+ /** Register an action programmatically. `valueType` declares the action's
494
+ * value shape (F1) defaults to `'digital'`, matching every call site
495
+ * written before F1. F3 — also snapshots `bindings` as this action's
496
+ * reset-to-defaults baseline (see `resetBindings`). */
497
+ registerAction(name: string, bindings: InputBinding[], valueType: ActionValueType = 'digital') {
153
498
  this.actions.set(name, bindings);
499
+ this.actionValueTypes.set(name, valueType);
500
+ this.defaultBindings.set(name, structuredClone(bindings));
501
+ }
502
+
503
+ /** The declared value type for a registered/loaded action (F1) — `'digital'`
504
+ * for an action that never specified one (including an unregistered/typo'd
505
+ * name — harmless, since every typed getter already treats an unknown
506
+ * action as inert). */
507
+ getActionValueType(actionName: string): ActionValueType {
508
+ return this.actionValueTypes.get(actionName) ?? 'digital';
509
+ }
510
+
511
+ /** Enforces the F1 type-safety AC: reading an action through a typed getter
512
+ * whose value type doesn't match the action's DECLARED `valueType` throws,
513
+ * naming both. Unknown actions (never registered/loaded) are exempt — they
514
+ * already read as inert/zeroed by every getter, matching pre-F1 behavior
515
+ * for a typo'd action name. */
516
+ private assertValueType(actionName: string, expected: ActionValueType, method: string): void {
517
+ const actual = this.actionValueTypes.get(actionName);
518
+ if (actual !== undefined && actual !== expected) {
519
+ throw new Error(
520
+ `InputManager.${method}: action "${actionName}" is declared valueType '${actual}', not ` +
521
+ `'${expected}'. Typed reads must match an action's declared valueType (F1, spec §12) — ` +
522
+ `use the getter for '${actual}' instead.`,
523
+ );
524
+ }
154
525
  }
155
526
 
156
527
  /** The names of all registered actions (for the InputAdapter to enumerate). */
@@ -160,13 +531,14 @@ export class InputManager {
160
531
 
161
532
  /** Poll gamepads and virtual look stick (call at start of frame) */
162
533
  poll() {
163
- if (!this.enabled) {
164
- // Input suspended (editor-gated play mode). Unlike keyboard/mouse,
165
- // gamepad state isn't DOM-event-driven — `isPressed`/`isJustPressed`/
166
- // `isJustReleased` read `this.gamepads` (and the edge-tracked sets)
167
- // directly, not a live poll of `navigator`. So gate the READ here: leave
168
- // `this.gamepads` empty so every gamepad-bound check sees no gamepads
169
- // and reports false. Deliberately leave the edge-tracking sets
534
+ if (!this.inputActive) {
535
+ // Input suspended (editor-gated play mode, OR — F2 — real window blur).
536
+ // Unlike keyboard/mouse, gamepad state isn't DOM-event-driven —
537
+ // `isPressed`/`isJustPressed`/`isJustReleased` read `this.gamepads`
538
+ // (and the edge-tracked sets) directly, not a live poll of `navigator`.
539
+ // So gate the READ here: leave `this.gamepads` empty so every
540
+ // gamepad-bound check sees no gamepads and reports false. Deliberately
541
+ // leave the edge-tracking sets
170
542
  // (gamepadButtons{Prev,Down,JustDown,JustUp}, gamepadAxes{Current,Prev})
171
543
  // untouched — they freeze at their last real values while disabled and
172
544
  // resume diffing against genuinely-last-observed hardware state once
@@ -194,6 +566,8 @@ export class InputManager {
194
566
  if (!current.has(key)) this.gamepadButtonsJustUp.add(key);
195
567
  }
196
568
  this.gamepadButtonsDown = current;
569
+ // F4 — a button press is unambiguous real gamepad input.
570
+ if (current.size > 0) this.lastActiveDevice = 'gamepad';
197
571
 
198
572
  // Snapshot this frame's raw axis values per (gamepad, axis) — independent
199
573
  // of any binding's direction/deadzone, mirroring the button snapshot
@@ -204,7 +578,11 @@ export class InputManager {
204
578
  const gp = this.gamepads[i];
205
579
  if (!gp) continue;
206
580
  for (let a = 0; a < gp.axes.length; a++) {
207
- currentAxes.set(`${i}:${a}`, gp.axes[a] ?? 0);
581
+ const value = gp.axes[a] ?? 0;
582
+ currentAxes.set(`${i}:${a}`, value);
583
+ // F4 — a stick pushed meaningfully off-center is also unambiguous
584
+ // real gamepad input (see `isStickActive`'s doc comment).
585
+ if (Math.abs(value) > 0.3) this.lastActiveDevice = 'gamepad';
208
586
  }
209
587
  }
210
588
  this.gamepadAxesCurrent = currentAxes;
@@ -229,6 +607,7 @@ export class InputManager {
229
607
 
230
608
  /** Is an action currently held down? */
231
609
  isPressed(actionName: string): boolean {
610
+ this.assertValueType(actionName, 'digital', 'isPressed');
232
611
  const bindings = this.actions.get(actionName);
233
612
  if (!bindings) return false;
234
613
 
@@ -254,6 +633,9 @@ export class InputManager {
254
633
  }
255
634
  break;
256
635
  }
636
+ case 'touch_button':
637
+ if (this.touchButtonsDown.has(binding.sourceId)) return true;
638
+ break;
257
639
  }
258
640
  }
259
641
  return false;
@@ -261,6 +643,7 @@ export class InputManager {
261
643
 
262
644
  /** Was an action pressed this frame (not held from previous)? */
263
645
  isJustPressed(actionName: string): boolean {
646
+ this.assertValueType(actionName, 'digital', 'isJustPressed');
264
647
  const bindings = this.actions.get(actionName);
265
648
  if (!bindings) return false;
266
649
 
@@ -294,6 +677,9 @@ export class InputManager {
294
677
  }
295
678
  break;
296
679
  }
680
+ case 'touch_button':
681
+ if (this.touchButtonsJustDown.has(binding.sourceId)) return true;
682
+ break;
297
683
  }
298
684
  }
299
685
  return false;
@@ -301,6 +687,7 @@ export class InputManager {
301
687
 
302
688
  /** Was an action released this frame (held last frame, up now)? */
303
689
  isJustReleased(actionName: string): boolean {
690
+ this.assertValueType(actionName, 'digital', 'isJustReleased');
304
691
  const bindings = this.actions.get(actionName);
305
692
  if (!bindings) return false;
306
693
 
@@ -331,11 +718,449 @@ export class InputManager {
331
718
  }
332
719
  break;
333
720
  }
721
+ case 'touch_button':
722
+ if (this.touchButtonsJustUp.has(binding.sourceId)) return true;
723
+ break;
334
724
  }
335
725
  }
336
726
  return false;
337
727
  }
338
728
 
729
+ /** Device/source metadata (F1 AC) for a digital action: which binding is
730
+ * CURRENTLY satisfying `isPressed`, or `null` if none is (including an
731
+ * unknown action). First-satisfied-binding-wins, mirroring isPressed's own
732
+ * iteration order. */
733
+ getDigitalSource(actionName: string): ActionValueSource | null {
734
+ this.assertValueType(actionName, 'digital', 'getDigitalSource');
735
+ if (!this.inputActive) return null;
736
+ const bindings = this.actions.get(actionName);
737
+ if (!bindings) return null;
738
+ for (const binding of bindings) {
739
+ switch (binding.type) {
740
+ case 'key':
741
+ if (this.keysDown.has(binding.code)) return { bindingType: 'key' };
742
+ break;
743
+ case 'mouse_button':
744
+ if (this.mouseButtons.has(binding.button)) return { bindingType: 'mouse_button' };
745
+ break;
746
+ case 'gamepad_button':
747
+ for (let i = 0; i < this.gamepads.length; i++) {
748
+ const gp = this.gamepads[i];
749
+ if (gp?.buttons[binding.button]?.pressed) {
750
+ return { bindingType: 'gamepad_button', gamepadIndex: i, gamepadId: gp.id };
751
+ }
752
+ }
753
+ break;
754
+ case 'gamepad_axis': {
755
+ const dz = binding.deadzone ?? 0.15;
756
+ for (let i = 0; i < this.gamepads.length; i++) {
757
+ const gp = this.gamepads[i];
758
+ if (!gp) continue;
759
+ const val = gp.axes[binding.axis] ?? 0;
760
+ if (InputManager.axisThresholdMet(val, binding.direction, dz)) {
761
+ return { bindingType: 'gamepad_axis', gamepadIndex: i, gamepadId: gp.id };
762
+ }
763
+ }
764
+ break;
765
+ }
766
+ case 'touch_button':
767
+ if (this.touchButtonsDown.has(binding.sourceId)) {
768
+ return { bindingType: 'touch_button', sourceId: binding.sourceId };
769
+ }
770
+ break;
771
+ }
772
+ }
773
+ return null;
774
+ }
775
+
776
+ // ==========================================================================
777
+ // F1 — typed scalar/vector2/pointer reads. See the class doc comment above
778
+ // for the deadzone/normalize/combine rules these implement.
779
+ // ==========================================================================
780
+
781
+ /** 1D deadzone rescale: magnitudes below `deadzone` become 0; magnitudes
782
+ * at/above it rescale from 0 (at the deadzone edge) to ±1 (at |raw| = 1),
783
+ * sign preserved. `raw` is clamped to [-1, 1] first so an out-of-range
784
+ * input can't overshoot past ±1 the other end. */
785
+ private static rescaleScalar(raw: number, deadzone: number): number {
786
+ const clamped = Math.max(-1, Math.min(1, raw));
787
+ const mag = Math.abs(clamped);
788
+ if (mag < deadzone) return 0;
789
+ const rescaled = deadzone >= 1 ? 0 : (mag - deadzone) / (1 - deadzone);
790
+ return Math.sign(clamped) * Math.min(1, rescaled);
791
+ }
792
+
793
+ /** Radial deadzone + unit-circle normalization for a Vector2: magnitude
794
+ * below `deadzone` snaps to {0,0}; at/above it, direction is preserved and
795
+ * magnitude is rescaled the same way `rescaleScalar` rescales a 1D
796
+ * magnitude (never renormalized UP past 1 — only ever scaled down). */
797
+ private static rescaleVector2(raw: Vector2, deadzone: number): Vector2 {
798
+ const mag = Math.hypot(raw.x, raw.y);
799
+ if (mag === 0 || mag < deadzone) return { x: 0, y: 0 };
800
+ const rescaledMag = Math.min(1, deadzone >= 1 ? 0 : (mag - deadzone) / (1 - deadzone));
801
+ const scale = rescaledMag / mag;
802
+ return { x: raw.x * scale, y: raw.y * scale };
803
+ }
804
+
805
+ /** Clamp a Vector2's magnitude to at most 1 (unit circle), preserving
806
+ * direction — the second half of the vector2 "sum-then-clamp" combine
807
+ * rule (a diagonal sum like {0.8, 0.8} has magnitude > 1). */
808
+ private static clampVector2(v: Vector2): Vector2 {
809
+ const mag = Math.hypot(v.x, v.y);
810
+ if (mag <= 1 || mag === 0) return v;
811
+ return { x: v.x / mag, y: v.y / mag };
812
+ }
813
+
814
+ /** Every `scalar`-relevant binding's (deadzone-applied) contribution for
815
+ * `actionName`, one entry per binding that can produce a scalar value —
816
+ * `gamepad_axis` (real device, already implemented pre-F1 for digital
817
+ * thresholding — F1 additionally reads its raw signed value for scalar
818
+ * actions, ignoring the binding's `direction` field, which is a digital-
819
+ * only hint) and `test_axis` (F1's injected-test-input source). `mouse_move`
820
+ * deliberately does NOT feed scalar (spec §12 F2 — no natural single-axis
821
+ * selector field is defined for it; it feeds pointerDelta/vector2 only —
822
+ * see `collectPointerDeltaContributions`/`collectVector2Contributions`). */
823
+ private collectScalarContributions(
824
+ actionName: string,
825
+ ): { value: number; source: ActionValueSource }[] {
826
+ if (!this.inputActive) return [];
827
+ const bindings = this.actions.get(actionName);
828
+ if (!bindings) return [];
829
+ const out: { value: number; source: ActionValueSource }[] = [];
830
+ for (const binding of bindings) {
831
+ if (binding.type === 'gamepad_axis') {
832
+ const dz = binding.deadzone ?? 0.15;
833
+ for (let i = 0; i < this.gamepads.length; i++) {
834
+ const gp = this.gamepads[i];
835
+ if (!gp) continue;
836
+ const raw = gp.axes[binding.axis] ?? 0;
837
+ out.push({
838
+ value: InputManager.rescaleScalar(raw, dz),
839
+ source: { bindingType: 'gamepad_axis', gamepadIndex: i, gamepadId: gp.id },
840
+ });
841
+ }
842
+ } else if (binding.type === 'test_axis') {
843
+ const raw = this.testAxisValues.get(binding.sourceId) ?? 0;
844
+ out.push({
845
+ value: InputManager.rescaleScalar(raw, binding.deadzone ?? 0),
846
+ source: { bindingType: 'test_axis', sourceId: binding.sourceId },
847
+ });
848
+ }
849
+ }
850
+ return out;
851
+ }
852
+
853
+ private computeScalar(actionName: string): { value: number; source: ActionValueSource | null } {
854
+ this.assertValueType(actionName, 'scalar', 'getScalar');
855
+ let best: { value: number; source: ActionValueSource } | null = null;
856
+ for (const c of this.collectScalarContributions(actionName)) {
857
+ if (!best || Math.abs(c.value) > Math.abs(best.value)) best = c;
858
+ }
859
+ if (!best || best.value === 0) return { value: 0, source: null };
860
+ return best;
861
+ }
862
+
863
+ /** Read a `'scalar'`-valueType action's current value: max-magnitude across
864
+ * contributing (deadzone-applied) bindings, sign preserved (combine rule —
865
+ * see the class doc comment). Throws if the action is declared a
866
+ * different valueType. */
867
+ getScalar(actionName: string): number {
868
+ return this.computeScalar(actionName).value;
869
+ }
870
+
871
+ /** Device/source metadata for {@link getScalar} — which binding is
872
+ * currently driving the combined value, or `null` if every contribution is
873
+ * zero (including an unknown action). */
874
+ getScalarSource(actionName: string): ActionValueSource | null {
875
+ return this.computeScalar(actionName).source;
876
+ }
877
+
878
+ /** `gamepad_axis_pair` contribution(s) for {@link collectVector2Contributions} — one entry
879
+ * per connected gamepad, deadzone-rescaled (default 0.15, matching `gamepad_axis`). Broken
880
+ * out to a helper so the per-gamepad loop doesn't add to the main dispatcher's complexity. */
881
+ private contributeGamepadAxisPair(
882
+ binding: Extract<InputBinding, { type: 'gamepad_axis_pair' }>,
883
+ ): { value: Vector2; source: ActionValueSource }[] {
884
+ const dz = binding.deadzone ?? 0.15;
885
+ const out: { value: Vector2; source: ActionValueSource }[] = [];
886
+ for (let i = 0; i < this.gamepads.length; i++) {
887
+ const gp = this.gamepads[i];
888
+ if (!gp) continue;
889
+ const raw = { x: gp.axes[binding.xAxis] ?? 0, y: gp.axes[binding.yAxis] ?? 0 };
890
+ out.push({
891
+ value: InputManager.rescaleVector2(raw, dz),
892
+ source: { bindingType: 'gamepad_axis_pair', gamepadIndex: i, gamepadId: gp.id },
893
+ });
894
+ }
895
+ return out;
896
+ }
897
+
898
+ /** Every `vector2`-relevant binding's (deadzone-applied) contribution:
899
+ * `test_vector2` (F1's injected-test-input source), plus F2's real-device
900
+ * cases — `gamepad_axis_pair` (see {@link contributeGamepadAxisPair}), `mouse_move` (the
901
+ * accumulated mouse delta, deadzone default 0 — raw pixel deltas have no natural "at rest"
902
+ * jitter floor the way an analog stick does), and `touch_stick` (a virtual on-screen stick,
903
+ * deadzone default 0). */
904
+ private collectVector2Contributions(
905
+ actionName: string,
906
+ ): { value: Vector2; source: ActionValueSource }[] {
907
+ if (!this.inputActive) return [];
908
+ const bindings = this.actions.get(actionName);
909
+ if (!bindings) return [];
910
+ const out: { value: Vector2; source: ActionValueSource }[] = [];
911
+ for (const binding of bindings) {
912
+ switch (binding.type) {
913
+ case 'test_vector2': {
914
+ const raw = this.testVector2Values.get(binding.sourceId) ?? { x: 0, y: 0 };
915
+ out.push({
916
+ value: InputManager.rescaleVector2(raw, binding.deadzone ?? 0),
917
+ source: { bindingType: 'test_vector2', sourceId: binding.sourceId },
918
+ });
919
+ break;
920
+ }
921
+ case 'gamepad_axis_pair':
922
+ out.push(...this.contributeGamepadAxisPair(binding));
923
+ break;
924
+ case 'mouse_move': {
925
+ const raw = { x: this.mouseDeltaX, y: this.mouseDeltaY };
926
+ out.push({
927
+ value: InputManager.rescaleVector2(raw, binding.deadzone ?? 0),
928
+ source: { bindingType: 'mouse_move' },
929
+ });
930
+ break;
931
+ }
932
+ case 'touch_stick': {
933
+ const raw = this.touchStickValues.get(binding.sourceId) ?? { x: 0, y: 0 };
934
+ out.push({
935
+ value: InputManager.rescaleVector2(raw, binding.deadzone ?? 0),
936
+ source: { bindingType: 'touch_stick', sourceId: binding.sourceId },
937
+ });
938
+ break;
939
+ }
940
+ }
941
+ }
942
+ return out;
943
+ }
944
+
945
+ private computeVector2(actionName: string): { value: Vector2; source: ActionValueSource | null } {
946
+ this.assertValueType(actionName, 'vector2', 'getVector2');
947
+ let sum: Vector2 = { x: 0, y: 0 };
948
+ let bestSource: ActionValueSource | null = null;
949
+ let bestMag = 0;
950
+ for (const c of this.collectVector2Contributions(actionName)) {
951
+ sum = { x: sum.x + c.value.x, y: sum.y + c.value.y };
952
+ const mag = Math.hypot(c.value.x, c.value.y);
953
+ if (mag > bestMag) {
954
+ bestMag = mag;
955
+ bestSource = c.source;
956
+ }
957
+ }
958
+ const clamped = InputManager.clampVector2(sum);
959
+ if (clamped.x === 0 && clamped.y === 0) return { value: clamped, source: null };
960
+ return { value: clamped, source: bestSource };
961
+ }
962
+
963
+ /** Read a `'vector2'`-valueType action's current value: sum contributing
964
+ * (deadzone-applied) {x,y} values component-wise, then clamp to the unit
965
+ * circle (combine rule — see the class doc comment). Throws if the action
966
+ * is declared a different valueType. */
967
+ getVector2(actionName: string): Vector2 {
968
+ return this.computeVector2(actionName).value;
969
+ }
970
+
971
+ /** Device/source metadata for {@link getVector2} — the contributing binding
972
+ * with the largest (pre-combine) magnitude, or `null` if the combined
973
+ * value is {0,0} (including an unknown action). */
974
+ getVector2Source(actionName: string): ActionValueSource | null {
975
+ return this.computeVector2(actionName).source;
976
+ }
977
+
978
+ /** Every `pointerDelta`-relevant binding's contribution: `test_pointer_delta`
979
+ * (F1's injected-test-input source) and — F2 — `mouse_move`, reading the
980
+ * real accumulated `mouseDeltaX/Y` (the same accumulator `onMouseMove`
981
+ * fills and `getMouseDelta()` reads). No deadzone for either (deltas are
982
+ * not deadzoned). */
983
+ private collectPointerDeltaContributions(
984
+ actionName: string,
985
+ ): { value: Vector2; source: ActionValueSource }[] {
986
+ if (!this.inputActive) return [];
987
+ const bindings = this.actions.get(actionName);
988
+ if (!bindings) return [];
989
+ const out: { value: Vector2; source: ActionValueSource }[] = [];
990
+ for (const binding of bindings) {
991
+ if (binding.type === 'test_pointer_delta') {
992
+ const value = this.testPointerDeltaAccum.get(binding.sourceId) ?? { x: 0, y: 0 };
993
+ out.push({
994
+ value,
995
+ source: { bindingType: 'test_pointer_delta', sourceId: binding.sourceId },
996
+ });
997
+ } else if (binding.type === 'mouse_move') {
998
+ out.push({
999
+ value: { x: this.mouseDeltaX, y: this.mouseDeltaY },
1000
+ source: { bindingType: 'mouse_move' },
1001
+ });
1002
+ }
1003
+ }
1004
+ return out;
1005
+ }
1006
+
1007
+ private computePointerDelta(actionName: string): {
1008
+ value: Vector2;
1009
+ source: ActionValueSource | null;
1010
+ } {
1011
+ this.assertValueType(actionName, 'pointerDelta', 'getPointerDelta');
1012
+ let sum: Vector2 = { x: 0, y: 0 };
1013
+ let bestSource: ActionValueSource | null = null;
1014
+ let bestMag = 0;
1015
+ for (const c of this.collectPointerDeltaContributions(actionName)) {
1016
+ sum = { x: sum.x + c.value.x, y: sum.y + c.value.y };
1017
+ const mag = Math.hypot(c.value.x, c.value.y);
1018
+ if (mag > bestMag) {
1019
+ bestMag = mag;
1020
+ bestSource = c.source;
1021
+ }
1022
+ }
1023
+ if (sum.x === 0 && sum.y === 0) return { value: sum, source: null };
1024
+ return { value: sum, source: bestSource };
1025
+ }
1026
+
1027
+ /** Read a `'pointerDelta'`-valueType action's this-frame movement: sum of
1028
+ * contributing bindings' deltas (combine rule — see the class doc
1029
+ * comment). Throws if the action is declared a different valueType. */
1030
+ getPointerDelta(actionName: string): Vector2 {
1031
+ return this.computePointerDelta(actionName).value;
1032
+ }
1033
+
1034
+ /** Device/source metadata for {@link getPointerDelta}. */
1035
+ getPointerDeltaSource(actionName: string): ActionValueSource | null {
1036
+ return this.computePointerDelta(actionName).source;
1037
+ }
1038
+
1039
+ /** Every `pointerPosition`-relevant binding's contribution, each tagged
1040
+ * with the injection-order sequence number used to break ties (last-
1041
+ * write-wins) — today only `test_pointer_position` (F1's injected-test-
1042
+ * input source). */
1043
+ private collectPointerPositionContributions(
1044
+ actionName: string,
1045
+ ): { value: Vector2; seq: number; source: ActionValueSource }[] {
1046
+ if (!this.inputActive) return [];
1047
+ const bindings = this.actions.get(actionName);
1048
+ if (!bindings) return [];
1049
+ const out: { value: Vector2; seq: number; source: ActionValueSource }[] = [];
1050
+ for (const binding of bindings) {
1051
+ if (binding.type === 'test_pointer_position') {
1052
+ const entry = this.testPointerPositionValues.get(binding.sourceId);
1053
+ if (entry) {
1054
+ out.push({
1055
+ value: entry.value,
1056
+ seq: entry.seq,
1057
+ source: { bindingType: 'test_pointer_position', sourceId: binding.sourceId },
1058
+ });
1059
+ }
1060
+ }
1061
+ }
1062
+ return out;
1063
+ }
1064
+
1065
+ private computePointerPosition(actionName: string): {
1066
+ value: Vector2;
1067
+ source: ActionValueSource | null;
1068
+ } {
1069
+ this.assertValueType(actionName, 'pointerPosition', 'getPointerPosition');
1070
+ const contributions = this.collectPointerPositionContributions(actionName);
1071
+ if (contributions.length === 0) return { value: { x: 0, y: 0 }, source: null };
1072
+ let best = contributions[0]!;
1073
+ for (const c of contributions) {
1074
+ if (c.seq > best.seq) best = c;
1075
+ }
1076
+ return { value: best.value, source: best.source };
1077
+ }
1078
+
1079
+ /** Read a `'pointerPosition'`-valueType action's current absolute position:
1080
+ * last-write-wins across contributing bindings (combine rule — see the
1081
+ * class doc comment; position isn't additive, so it is never summed).
1082
+ * Throws if the action is declared a different valueType. */
1083
+ getPointerPosition(actionName: string): Vector2 {
1084
+ return this.computePointerPosition(actionName).value;
1085
+ }
1086
+
1087
+ /** Device/source metadata for {@link getPointerPosition} — the most
1088
+ * recently-written contributing source. */
1089
+ getPointerPositionSource(actionName: string): ActionValueSource | null {
1090
+ return this.computePointerPosition(actionName).source;
1091
+ }
1092
+
1093
+ /** Generic typed read (F1): dispatches to the matching typed getter based
1094
+ * on `expectedType`, returning `ActionValueOf<T>` — a caller with a
1095
+ * statically-known action-value-type map gets a fully-inferred, non-`any`
1096
+ * return type. Throws the same mismatch error as the dedicated getters if
1097
+ * the action's declared valueType disagrees with `expectedType`. */
1098
+ readAction<T extends ActionValueType>(actionName: string, expectedType: T): ActionValueOf<T> {
1099
+ switch (expectedType) {
1100
+ case 'digital':
1101
+ return this.isPressed(actionName) as ActionValueOf<T>;
1102
+ case 'scalar':
1103
+ return this.getScalar(actionName) as ActionValueOf<T>;
1104
+ case 'vector2':
1105
+ return this.getVector2(actionName) as ActionValueOf<T>;
1106
+ case 'pointerDelta':
1107
+ return this.getPointerDelta(actionName) as ActionValueOf<T>;
1108
+ case 'pointerPosition':
1109
+ return this.getPointerPosition(actionName) as ActionValueOf<T>;
1110
+ default:
1111
+ throw new Error(`InputManager.readAction: unknown value type "${expectedType as string}"`);
1112
+ }
1113
+ }
1114
+
1115
+ /** Inject a synthetic scalar value for a named test source (F1's injected-
1116
+ * test-input hook — also the shape of F2's "injected test input" backend).
1117
+ * Bind an action to `{ type: 'test_axis', sourceId }` (`valueType:
1118
+ * 'scalar'`) to read it back via {@link getScalar}. The value PERSISTS
1119
+ * across frames (like a real analog stick held in position) until changed
1120
+ * or cleared via {@link clearAxis}. */
1121
+ injectAxis(sourceId: string, value: number): void {
1122
+ this.testAxisValues.set(sourceId, value);
1123
+ }
1124
+
1125
+ /** Clear an injected scalar source (e.g. simulating an at-rest/disconnected
1126
+ * device). */
1127
+ clearAxis(sourceId: string): void {
1128
+ this.testAxisValues.delete(sourceId);
1129
+ }
1130
+
1131
+ /** Inject a synthetic {x,y} value for a named test source. Bind an action
1132
+ * to `{ type: 'test_vector2', sourceId }` (`valueType: 'vector2'`) to read
1133
+ * it back via {@link getVector2}. Persists across frames until changed or
1134
+ * cleared via {@link clearVector2}. */
1135
+ injectVector2(sourceId: string, value: Vector2): void {
1136
+ this.testVector2Values.set(sourceId, value);
1137
+ }
1138
+
1139
+ /** Clear an injected Vector2 source. */
1140
+ clearVector2(sourceId: string): void {
1141
+ this.testVector2Values.delete(sourceId);
1142
+ }
1143
+
1144
+ /** Accumulate a synthetic pointer delta for a named test source — mirrors
1145
+ * real mouse-move delta accumulation (`onMouseMove` above): multiple calls
1146
+ * within the same frame sum, and the accumulator is cleared in
1147
+ * {@link endFrame}. Bind an action to `{ type: 'test_pointer_delta',
1148
+ * sourceId }` (`valueType: 'pointerDelta'`) to read it back via
1149
+ * {@link getPointerDelta}. */
1150
+ injectPointerDelta(sourceId: string, delta: Vector2): void {
1151
+ const prev = this.testPointerDeltaAccum.get(sourceId) ?? { x: 0, y: 0 };
1152
+ this.testPointerDeltaAccum.set(sourceId, { x: prev.x + delta.x, y: prev.y + delta.y });
1153
+ }
1154
+
1155
+ /** Set a synthetic absolute pointer position for a named test source.
1156
+ * Persists (last-write-wins across contributing sources on read — see the
1157
+ * pointerPosition combine rule) until changed. Bind an action to `{ type:
1158
+ * 'test_pointer_position', sourceId }` (`valueType: 'pointerPosition'`) to
1159
+ * read it back via {@link getPointerPosition}. */
1160
+ injectPointerPosition(sourceId: string, value: Vector2): void {
1161
+ this.testPointerPositionValues.set(sourceId, { value, seq: ++this.pointerPositionSeq });
1162
+ }
1163
+
339
1164
  /** Whether input capture is currently enabled (see {@link setEnabled}). */
340
1165
  isEnabled(): boolean {
341
1166
  return this.enabled;
@@ -344,24 +1169,46 @@ export class InputManager {
344
1169
  /**
345
1170
  * Enable/disable input capture. The editor host disables the running game's
346
1171
  * input while its viewport isn't focused so keystrokes don't leak in from
347
- * the editor. Disabling clears all held/transient keyboard/mouse state so
348
- * nothing sticks (e.g. a held movement key when you switch back to the
349
- * Scene tab mid-flight) — gamepad state doesn't need clearing here since
350
- * `poll()` itself stops reading it while disabled (see poll()'s doc comment).
1172
+ * the editor. Disabling clears all held/transient keyboard/mouse/touch-button
1173
+ * state (via {@link flushHeldState}) so nothing sticks (e.g. a held movement
1174
+ * key when you switch back to the Scene tab mid-flight) — gamepad state
1175
+ * doesn't need clearing here since `poll()` itself stops reading it while
1176
+ * disabled (see poll()'s doc comment).
351
1177
  */
352
1178
  setEnabled(enabled: boolean) {
353
1179
  if (this.enabled === enabled) return;
354
1180
  this.enabled = enabled;
355
- if (!enabled) {
356
- this.keysDown.clear();
357
- this.keysJustDown.clear();
358
- this.keysJustUp.clear();
359
- this.mouseButtons.clear();
360
- this.mouseButtonsJustDown.clear();
361
- this.mouseButtonsJustUp.clear();
362
- this.mouseDeltaX = 0;
363
- this.mouseDeltaY = 0;
364
- }
1181
+ if (!enabled) this.flushHeldState();
1182
+ }
1183
+
1184
+ /** Whether the window/tab is currently focused (F2, spec §12 "focus
1185
+ * gating") — `false` while blurred (real `blur`/`focus` events; see the
1186
+ * constructor). Distinct from {@link isEnabled}, which is the editor's
1187
+ * explicit play-mode gate. */
1188
+ isFocused(): boolean {
1189
+ return this.focused;
1190
+ }
1191
+
1192
+ /** Whether the pointer is currently locked to {@link requestPointerLock}'s
1193
+ * element (F2, spec §12 "pointer lock") — tracked via the real
1194
+ * `pointerlockchange` event. */
1195
+ isPointerLocked(): boolean {
1196
+ return this.pointerLocked;
1197
+ }
1198
+
1199
+ /** Device identity for a connected gamepad slot (F2, spec §12 "expose
1200
+ * device identity" + "prefer the standard mapping"), or `null` if nothing
1201
+ * is connected at `index` (including while input is disabled/unfocused,
1202
+ * since `poll()` empties `this.gamepads` then). `mapping` is the Gamepad
1203
+ * API's own `'standard'`/`''` value — bindings author button/axis indices
1204
+ * assuming the W3C Standard Gamepad layout, so a non-`'standard'` mapping
1205
+ * means those indices may not correspond as documented. */
1206
+ getGamepadInfo(
1207
+ index: number,
1208
+ ): { index: number; id: string; mapping: string; connected: boolean } | null {
1209
+ const gp = this.gamepads[index];
1210
+ if (!gp) return null;
1211
+ return { index, id: gp.id, mapping: gp.mapping, connected: gp.connected };
365
1212
  }
366
1213
 
367
1214
  /** Get mouse movement delta this frame */
@@ -369,6 +1216,278 @@ export class InputManager {
369
1216
  return { x: this.mouseDeltaX, y: this.mouseDeltaY };
370
1217
  }
371
1218
 
1219
+ /**
1220
+ * F4 (spec §12 "Schema and Examples" — the F3 prompt-switching gap): which
1221
+ * device family most recently produced real input, or `null` before any
1222
+ * input has arrived. A prompt UI polls this (or lets {@link getPrompt}
1223
+ * consult it automatically by omitting `device`) to switch its displayed
1224
+ * label as the player switches controllers — e.g. showing `'Space'` while
1225
+ * they're on keyboard, then `'A'` the moment they touch a gamepad.
1226
+ */
1227
+ getLastActiveDevice(): PromptDevice | null {
1228
+ return this.lastActiveDevice;
1229
+ }
1230
+
1231
+ // ==========================================================================
1232
+ // F3 — rebinding: list/replace/add/remove/reset/persist. See the class doc
1233
+ // comment above for the full design note.
1234
+ // ==========================================================================
1235
+
1236
+ /** The current bindings for `actionName` (F3 AC "listed") — a defensive
1237
+ * deep clone, so mutating the returned array/objects never affects the
1238
+ * live binding list. `[]` for an unknown/never-registered action. */
1239
+ getBindings(actionName: string): InputBinding[] {
1240
+ const bindings = this.actions.get(actionName);
1241
+ return bindings ? structuredClone(bindings) : [];
1242
+ }
1243
+
1244
+ /** Wholesale-replace `actionName`'s bindings array (F3 AC "replaced"). Does
1245
+ * NOT touch the reset-to-defaults snapshot — see `resetBindings`. A no-op
1246
+ * target for an action that was never registered is allowed (it simply
1247
+ * registers one with `valueType: 'digital'`, matching `registerAction`'s
1248
+ * own default) since a rebinding UI operates on actions it already knows
1249
+ * about, but shouldn't have to special-case "brand new action" separately. */
1250
+ setBindings(actionName: string, bindings: InputBinding[]): void {
1251
+ if (!this.actionValueTypes.has(actionName)) this.actionValueTypes.set(actionName, 'digital');
1252
+ this.actions.set(actionName, structuredClone(bindings));
1253
+ }
1254
+
1255
+ /** Append one binding to `actionName` (F3 AC "added"). */
1256
+ addBinding(actionName: string, binding: InputBinding): void {
1257
+ const bindings = this.actions.get(actionName) ?? [];
1258
+ this.actions.set(actionName, [...bindings, structuredClone(binding)]);
1259
+ if (!this.actionValueTypes.has(actionName)) this.actionValueTypes.set(actionName, 'digital');
1260
+ }
1261
+
1262
+ /** Remove the binding at `index` from `actionName` (F3 AC "removed"). A
1263
+ * no-op if the action is unknown or `index` is out of range (rather than
1264
+ * throwing) — mirrors every other getter's "unknown action is inert"
1265
+ * convention, so a UI doesn't need a separate existence check first. */
1266
+ removeBinding(actionName: string, index: number): void {
1267
+ const bindings = this.actions.get(actionName);
1268
+ if (!bindings || index < 0 || index >= bindings.length) return;
1269
+ this.actions.set(actionName, [...bindings.slice(0, index), ...bindings.slice(index + 1)]);
1270
+ }
1271
+
1272
+ /** Replace a single binding slot at `index` on `actionName` — the shape a
1273
+ * rebinding UI actually wants ("rebind THIS control"), built on
1274
+ * `getBindings`/`setBindings`. A no-op if the action is unknown or `index`
1275
+ * is out of range. */
1276
+ replaceBinding(actionName: string, index: number, binding: InputBinding): void {
1277
+ const bindings = this.actions.get(actionName);
1278
+ if (!bindings || index < 0 || index >= bindings.length) return;
1279
+ const next = bindings.slice();
1280
+ next[index] = structuredClone(binding);
1281
+ this.actions.set(actionName, next);
1282
+ }
1283
+
1284
+ /** Restore `actionName`'s bindings to the snapshot taken at
1285
+ * `registerAction`/`loadMap`/`loadMapObject` time (F3 AC "reset"). A no-op
1286
+ * for an action with no snapshot (never registered/loaded — there is no
1287
+ * "default" to reset to). */
1288
+ resetBindings(actionName: string): void {
1289
+ const defaults = this.defaultBindings.get(actionName);
1290
+ if (!defaults) return;
1291
+ this.actions.set(actionName, structuredClone(defaults));
1292
+ }
1293
+
1294
+ /** Reset every action that has a snapshot back to its registered/loaded
1295
+ * defaults. Convenience batch form of `resetBindings`. */
1296
+ resetAllBindings(): void {
1297
+ for (const actionName of this.defaultBindings.keys()) this.resetBindings(actionName);
1298
+ }
1299
+
1300
+ /**
1301
+ * Whether `a` and `b` are the SAME physical input (F3's structural-conflict
1302
+ * identity check — see `findConflicts`). Deliberately narrower than deep
1303
+ * equality: `deadzone` never participates (two bindings on the same key
1304
+ * with different deadzones are still the same physical control), and a
1305
+ * `gamepad_axis`'s `direction` DOES participate — opposite directions of one
1306
+ * physical axis (e.g. "accelerate" on positive, "brake" on negative) are a
1307
+ * legitimate, non-conflicting pair, not a collision.
1308
+ */
1309
+ private static bindingsCollide(a: InputBinding, b: InputBinding): boolean {
1310
+ if (a.type !== b.type) return false;
1311
+ switch (a.type) {
1312
+ case 'key':
1313
+ return a.code === (b as typeof a).code;
1314
+ case 'mouse_button':
1315
+ return a.button === (b as typeof a).button;
1316
+ case 'mouse_move':
1317
+ return true; // there is only one physical mouse-move source
1318
+ case 'gamepad_button':
1319
+ return a.button === (b as typeof a).button;
1320
+ case 'gamepad_axis': {
1321
+ const other = b as typeof a;
1322
+ return a.axis === other.axis && a.direction === other.direction;
1323
+ }
1324
+ case 'gamepad_axis_pair': {
1325
+ const other = b as typeof a;
1326
+ return a.xAxis === other.xAxis && a.yAxis === other.yAxis;
1327
+ }
1328
+ case 'touch_button':
1329
+ case 'touch_stick':
1330
+ case 'test_axis':
1331
+ case 'test_vector2':
1332
+ case 'test_pointer_delta':
1333
+ case 'test_pointer_position':
1334
+ return a.sourceId === (b as typeof a).sourceId;
1335
+ default:
1336
+ return false;
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * Every EXISTING binding, on every OTHER registered action, that is
1342
+ * structurally the same physical input as `proposed` (F3 AC "conflicts are
1343
+ * detected and returned structurally") — never throws, always a plain
1344
+ * (possibly empty) array a rebinding UI inspects before committing a
1345
+ * rebind. `excludeActionName` is normally the action being rebound itself
1346
+ * (so an action isn't reported as conflicting with its own current
1347
+ * binding when re-proposing an unchanged value).
1348
+ */
1349
+ findConflicts(proposed: InputBinding, excludeActionName?: string): BindingConflict[] {
1350
+ const conflicts: BindingConflict[] = [];
1351
+ for (const [actionName, bindings] of this.actions) {
1352
+ if (actionName === excludeActionName) continue;
1353
+ bindings.forEach((binding, bindingIndex) => {
1354
+ if (InputManager.bindingsCollide(proposed, binding)) {
1355
+ conflicts.push({ actionName, bindingIndex, binding: structuredClone(binding) });
1356
+ }
1357
+ });
1358
+ }
1359
+ return conflicts;
1360
+ }
1361
+
1362
+ /**
1363
+ * Serialize every registered/loaded action back into the `.inputmap.json`
1364
+ * document shape (F3 AC "persisted") — the exact `InputMapFile` shape
1365
+ * `loadMap`/`loadMapObject` read, so `manager.loadMapObject(manager.toInputMapFile())`
1366
+ * on a fresh instance reproduces the same live bindings. `version` is
1367
+ * carried forward from the last loaded map when known, else `1` (a
1368
+ * manager built purely via `registerAction` calls, never `loadMap`, has no
1369
+ * file-format version to preserve).
1370
+ */
1371
+ toInputMapFile(): InputMapFile {
1372
+ const actions: InputMapFile['actions'] = {};
1373
+ for (const [name, bindings] of this.actions) {
1374
+ actions[name] = {
1375
+ valueType: this.actionValueTypes.get(name) ?? 'digital',
1376
+ bindings: structuredClone(bindings),
1377
+ };
1378
+ }
1379
+ return { version: this.loadedMapVersion, actions };
1380
+ }
1381
+
1382
+ /**
1383
+ * Resolve a display prompt (F3 AC: labels/icons like `'Space'`, `'A'`, or a
1384
+ * touch control's name) for whichever of `actionName`'s bindings belongs to
1385
+ * the requested `device` family. `null` when the action has no binding for
1386
+ * that device (including an unknown action). `gamepadIndex` (default 0)
1387
+ * selects which connected pad's `mapping`/connected state to consult for a
1388
+ * `gamepad_*` binding — see `getGamepadInfo`.
1389
+ *
1390
+ * F4 — `device` is now OPTIONAL: omit it (or pass `undefined`) to resolve
1391
+ * against {@link getLastActiveDevice} instead (falling back to `'keyboard'`
1392
+ * before any input has arrived), so a prompt UI that wants "show whatever
1393
+ * the player is actually holding right now" doesn't have to track the
1394
+ * active device itself — it can just call `getPrompt(actionName)` every
1395
+ * frame and the label switches on its own as the player changes controllers.
1396
+ * Passing an explicit `device` (as every pre-F4 caller does) is unaffected.
1397
+ */
1398
+ getPrompt(actionName: string, device?: PromptDevice, gamepadIndex = 0): BindingPrompt | null {
1399
+ const resolvedDevice = device ?? this.lastActiveDevice ?? 'keyboard';
1400
+ const bindings = this.actions.get(actionName);
1401
+ if (!bindings) return null;
1402
+ const binding = bindings.find((b) => bindingDeviceFamily(b.type) === resolvedDevice);
1403
+ if (!binding) return null;
1404
+
1405
+ switch (binding.type) {
1406
+ case 'key':
1407
+ return {
1408
+ device: 'keyboard',
1409
+ bindingType: 'key',
1410
+ label: keyLabel(binding.code),
1411
+ icon: keyLabel(binding.code),
1412
+ };
1413
+ case 'mouse_button':
1414
+ return {
1415
+ device: 'mouse',
1416
+ bindingType: 'mouse_button',
1417
+ label: mouseButtonLabel(binding.button),
1418
+ icon: 'mouse',
1419
+ };
1420
+ case 'mouse_move':
1421
+ return { device: 'mouse', bindingType: 'mouse_move', label: 'Mouse', icon: 'mouse' };
1422
+ case 'gamepad_button': {
1423
+ const info = this.getGamepadInfo(gamepadIndex);
1424
+ if (!info?.connected) {
1425
+ return {
1426
+ device: 'gamepad',
1427
+ bindingType: 'gamepad_button',
1428
+ label: 'Gamepad Disconnected',
1429
+ icon: 'gamepad-off',
1430
+ };
1431
+ }
1432
+ const label =
1433
+ info.mapping === 'standard'
1434
+ ? (STANDARD_GAMEPAD_BUTTON_LABELS[binding.button] ?? `Button ${binding.button}`)
1435
+ : `Button ${binding.button}`;
1436
+ return { device: 'gamepad', bindingType: 'gamepad_button', label, icon: label };
1437
+ }
1438
+ case 'gamepad_axis': {
1439
+ const info = this.getGamepadInfo(gamepadIndex);
1440
+ if (!info?.connected) {
1441
+ return {
1442
+ device: 'gamepad',
1443
+ bindingType: 'gamepad_axis',
1444
+ label: 'Gamepad Disconnected',
1445
+ icon: 'gamepad-off',
1446
+ };
1447
+ }
1448
+ return {
1449
+ device: 'gamepad',
1450
+ bindingType: 'gamepad_axis',
1451
+ label: gamepadAxisLabel(binding.axis),
1452
+ icon: 'gamepad-stick',
1453
+ };
1454
+ }
1455
+ case 'gamepad_axis_pair': {
1456
+ const info = this.getGamepadInfo(gamepadIndex);
1457
+ if (!info?.connected) {
1458
+ return {
1459
+ device: 'gamepad',
1460
+ bindingType: 'gamepad_axis_pair',
1461
+ label: 'Gamepad Disconnected',
1462
+ icon: 'gamepad-off',
1463
+ };
1464
+ }
1465
+ return {
1466
+ device: 'gamepad',
1467
+ bindingType: 'gamepad_axis_pair',
1468
+ label: gamepadAxisPairLabel(binding.xAxis, binding.yAxis),
1469
+ icon: 'gamepad-stick',
1470
+ };
1471
+ }
1472
+ case 'touch_button':
1473
+ return {
1474
+ device: 'touch',
1475
+ bindingType: 'touch_button',
1476
+ label: binding.sourceId,
1477
+ icon: 'touch',
1478
+ };
1479
+ case 'touch_stick':
1480
+ return {
1481
+ device: 'touch',
1482
+ bindingType: 'touch_stick',
1483
+ label: binding.sourceId,
1484
+ icon: 'touch',
1485
+ };
1486
+ default:
1487
+ return null;
1488
+ }
1489
+ }
1490
+
372
1491
  /** Call at end of frame to clear per-frame state */
373
1492
  endFrame() {
374
1493
  this.keysJustDown.clear();
@@ -385,6 +1504,13 @@ export class InputManager {
385
1504
  this.gamepadAxesPrev = this.gamepadAxesCurrent;
386
1505
  this.mouseDeltaX = 0;
387
1506
  this.mouseDeltaY = 0;
1507
+ // F1 — pointerDelta is a per-frame accumulator, same shape as
1508
+ // mouseDeltaX/Y above; axis/vector2/pointerPosition are level values and
1509
+ // deliberately NOT reset here (they persist until changed/cleared).
1510
+ this.testPointerDeltaAccum.clear();
1511
+ // F2 — touch-button edges, same shape as the mouse-button edges above.
1512
+ this.touchButtonsJustDown.clear();
1513
+ this.touchButtonsJustUp.clear();
388
1514
  }
389
1515
 
390
1516
  /** Inject synthetic key state from touch controls */
@@ -410,6 +1536,49 @@ export class InputManager {
410
1536
  this.mouseDeltaY += dy;
411
1537
  }
412
1538
 
1539
+ /**
1540
+ * F2 (spec §12 "Complete Device Backends") — the touch/virtual-control
1541
+ * digital backend. Set a named virtual button's (`sourceId` — a zone/knob
1542
+ * id the touch UI chooses) pressed state; edge-tracked exactly like a real
1543
+ * mouse button (`onMouseDown`/`onMouseUp` above) so `isJustPressed`/
1544
+ * `isJustReleased` work for `{ type: 'touch_button', sourceId }` bindings.
1545
+ * This is the injectable entry point a real on-screen button's own
1546
+ * touchstart/touchend handler would call — that DOM wiring is a thin
1547
+ * adapter at the edge (browser-verified separately); this method is the
1548
+ * MAPPING logic, unit-tested headlessly with no DOM.
1549
+ */
1550
+ setTouchButton(sourceId: string, pressed: boolean): void {
1551
+ if (!this.inputActive) return;
1552
+ if (pressed) {
1553
+ if (!this.touchButtonsDown.has(sourceId)) this.touchButtonsJustDown.add(sourceId);
1554
+ this.touchButtonsDown.add(sourceId);
1555
+ this.lastActiveDevice = 'touch'; // F4
1556
+ } else {
1557
+ if (this.touchButtonsDown.has(sourceId)) this.touchButtonsJustUp.add(sourceId);
1558
+ this.touchButtonsDown.delete(sourceId);
1559
+ }
1560
+ }
1561
+
1562
+ /**
1563
+ * F2 — the touch/virtual-control analog backend. Set a named virtual
1564
+ * stick's (`sourceId`) raw `{x,y}` position (e.g. -1..1 per axis, like a
1565
+ * gamepad stick); read back via `getVector2` on a `{ type: 'touch_stick',
1566
+ * sourceId }` binding, deadzone-rescaled the same way a gamepad stick is.
1567
+ * Persists across frames (a level value, like a real analog stick held in
1568
+ * position) until changed or cleared via {@link clearTouchStick}.
1569
+ */
1570
+ setTouchStick(sourceId: string, value: Vector2): void {
1571
+ this.touchStickValues.set(sourceId, value);
1572
+ // F4 — mirrors the gamepad-axis heuristic in poll() (see `isStickActive`).
1573
+ if (InputManager.isStickActive(value)) this.lastActiveDevice = 'touch';
1574
+ }
1575
+
1576
+ /** Clear a virtual stick's value (e.g. simulating the touch point lifting —
1577
+ * the knob returns to center). */
1578
+ clearTouchStick(sourceId: string): void {
1579
+ this.touchStickValues.delete(sourceId);
1580
+ }
1581
+
413
1582
  /** Request pointer lock for FPS-style mouse control */
414
1583
  requestPointerLock(element: HTMLElement) {
415
1584
  // Idempotent: never stack duplicate click listeners. Rewire if the element
@@ -431,6 +1600,11 @@ export class InputManager {
431
1600
  window.removeEventListener('mousedown', this.onMouseDown);
432
1601
  window.removeEventListener('mouseup', this.onMouseUp);
433
1602
  window.removeEventListener('mousemove', this.onMouseMove);
1603
+ window.removeEventListener('blur', this.onWindowBlur);
1604
+ window.removeEventListener('focus', this.onWindowFocus);
1605
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
1606
+ document.removeEventListener('pointerlockchange', this.onPointerLockChange);
1607
+ }
434
1608
  if (this.pointerLockElement) {
435
1609
  this.pointerLockElement.removeEventListener('click', this.onPointerLockClick);
436
1610
  this.pointerLockElement = null;